all repos

onasty @ 20c3a33

a one-time notes service
28 files changed, 969 insertions(+), 132 deletions(-)
feat: send emails on sign-up (#6)

* feat: add mailer interface

* feat(mailer): make the simplest impl of email sender

* chore: add migrations

* add boilerplate for verification tokens

* setup boilerplate for mailgun

* refactor(mailer): set mailgun region to EU

* feat(config): add all what needed for mailgun

* feat: store verification token on sign up

* fix: set ttl when save token

before refresh token didnt have expiration data but i should

* fixup! fix: set ttl when save token

* it should've sent eamil but i'm getting freaking error

* refactor(mailer): dont change the mailgun region by default

* feat(vertokrepo): add some functional that would be need to mark user as verified

* refactor(repo): make activation methods more "smart"

* feat(usersrv): impl user verification

* feat(api): verification

* refactor(vertokrepo): fix linter

* refactor(vertokrepo): refactor

* fix(userepo): not it can mark user as activated

* fix(e2e): init app correctly

* fix(e2e): setup mock server for mailgun

* feat: dont allow non activaed user to be authorized

* refactor: remove debug prints

* refactor(apiv1): delete most of duplicated code

* chore(docker-compose): change postgres image to alpine one

* feat(auth): dont allow non-activated user to login

* refactor(auth): make /verify GET, so it can be accessed just by typing
in browser

* feat(auth): request to resend verification email

* fix(config): panic when config values arent valid

* refactor(usersrv): renaminv

* refactor(apiv1): error reponses

* refactor(config): set defaults to TTLs

* refactor(logger): add option to show caller

* feat(e2e): setup custom logger for tests

* fix(sessionrepo): fix refreshing of tokens

* refactor(e2e): setup config for tests manually

* refactor: reformat sql

* feat(auth): reset password

* test(e2e): change password

* fixup! feat(auth): reset password

* refactor e2e

* refactor: some refactoring and comments for the future

* remove usless comment

* TODO REMOVE ME

somehow i cannot connect to postgres locally neither in tests nor just
running app. so its just test for ci

* Revert 1 commits

7766216 'TODO REMOVE ME'

* fix(psqlutil): use new verrsion of libs

* verify if user is activated in tests, ig

* Revert "verify if user is activated in tests, ig"

This reverts commit 171432770ad28b7da2c9bc57a6b93e2e2d0795df.

* refactor(e2e): linter

* test(e2e): add email verification test

* refactor(e2e): logger

* refactor(mailer): remove unused mailgun public method

* feat(mailer): setup custom mailer for tests

* feat(mailgun): add some logging

* refactor(api): show that email is verified on the screen

* test(e2e): resent of the verification mails

* renaming

* fix(vertokrepo): i forgor to call .commit, freak

* vertokrepo: reformat some core

* fix(usersrv): fix the verification link

* feat(userepo): add evrything for resetting password

* feat(usersrv): forget password

* feat(api): forget password

* ranaming

* fix typos

* test(e2e): check if user is actiavted on signing in

* Revert "feat(usersrv): forget password"

This reverts commit 4aa7da57cd348056e98b4c5765fbc42a35875613.

* Revert "feat(api): forget password"

This reverts commit 241b10825f33bc698fa8d31b2be53e3ea31a34a6.

* refactor(e2e): use config in setting up

* docs(mailer): add comments

* test(mailer): test testing mailer

* test(e2e): resend verification mail

* refactor(e2e): renaming

* refactor(e2e): improve change password test
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2024-09-09 13:02:26 +0300
Parent: e0dc5bb
M .env.example

@@ -1,17 +1,23 @@

-APP_ENV="debug" +APP_ENV=debug SERVER_PORT=3000 -PASSWORD_SALT="onasty" +PASSWORD_SALT=onasty -LOG_LEVEL="debug" -LOG_FORMAT="text" +LOG_LEVEL=debug +LOG_FORMAT=text +LOG_SHOW_LINE=true -JWT_SIGNING_KEY="supersecret" -JWT_ACCESS_TOKEN_TTL="30m" -JWT_REFRESH_TOKEN_TTL="15d" +JWT_SIGNING_KEY=supersecret +JWT_ACCESS_TOKEN_TTL=30m +JWT_REFRESH_TOKEN_TTL=360d -POSTGRES_USERNAME="onasty" -POSTGRES_PASSWORD="qwerty" -POSTGRES_HOST="127.0.0.1" +POSTGRES_USERNAME=onasty +POSTGRES_PASSWORD=qwerty +POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432 -POSTGRES_DATABASE="onasty" +POSTGRES_DATABASE=onasty POSTGRESQL_DSN="postgres://$POSTGRES_USERNAME:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DATABASE?sslmode=disable" + +MAILGUN_FROM=onasty@mail.com +MAILGUN_DOMAI='<domain>' +MAILGUN_API_KEY='<token>' +VERIFICATION_TOKEN_TTL=48h
M cmd/server/main.go

@@ -13,11 +13,13 @@ "github.com/gin-gonic/gin"

"github.com/olexsmir/onasty/internal/config" "github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/jwtutil" + "github.com/olexsmir/onasty/internal/mailer" "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" "github.com/olexsmir/onasty/internal/store/psql/noterepo" "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" "github.com/olexsmir/onasty/internal/store/psql/userepo" + "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" "github.com/olexsmir/onasty/internal/store/psqlutil" httptransport "github.com/olexsmir/onasty/internal/transport/http" "github.com/olexsmir/onasty/internal/transport/http/httpserver"

@@ -51,11 +53,22 @@

// app deps sha256Hasher := hasher.NewSHA256Hasher(cfg.PasswordSalt) jwtTokenizer := jwtutil.NewJWTUtil(cfg.JwtSigningKey, cfg.JwtAccessTokenTTL) + mailGunMailer := mailer.NewMailgun(cfg.MailgunFrom, cfg.MailgunDomain, cfg.MailgunAPIKey) sessionrepo := sessionrepo.New(psqlDB) + vertokrepo := vertokrepo.New(psqlDB) userepo := userepo.New(psqlDB) - usersrv := usersrv.New(userepo, sessionrepo, sha256Hasher, jwtTokenizer) + usersrv := usersrv.New( + userepo, + sessionrepo, + vertokrepo, + sha256Hasher, + jwtTokenizer, + mailGunMailer, + cfg.JwtRefreshTokenTTL, + cfg.VerficationTokenTTL, + ) noterepo := noterepo.New(psqlDB) notesrv := notesrv.New(noterepo)

@@ -65,7 +78,7 @@

// http server srv := httpserver.NewServer(cfg.ServerPort, handler.Handler()) go func() { - slog.Info("starting http server", "port", cfg.ServerPort) + slog.Debug("starting http server", "port", cfg.ServerPort) if err := srv.Start(); !errors.Is(err, http.ErrServerClosed) { slog.Error("failed to start http server", "error", err) }

@@ -100,12 +113,17 @@ if !ok {

return errors.New("unknown log level") } + handlerOptions := &slog.HandlerOptions{ + Level: logLevel, + AddSource: cfg.LogShowLine, + } + var slogHandler slog.Handler switch cfg.LogFormat { case "json": - slogHandler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}) + slogHandler = slog.NewJSONHandler(os.Stdout, handlerOptions) case "text": - slogHandler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}) + slogHandler = slog.NewTextHandler(os.Stdout, handlerOptions) default: return errors.New("unknown log format") }
M docker-compose.yml

@@ -1,6 +1,6 @@

services: postgres: - image: postgres:16 + image: postgres:16-alpine container_name: onasty-postgres environment: POSTGRES_USER: onasty
M e2e/apiv1_auth_test.go

@@ -4,6 +4,7 @@ import (

"net/http" "github.com/gofrs/uuid/v5" + "github.com/olexsmir/onasty/internal/models" ) type apiv1AuthSignUpRequest struct {

@@ -81,12 +82,109 @@ AccessToken string `json:"access_token"`

RefreshToken string `json:"refresh_token"` } ) + +func (e *AppTestSuite) TestAuthV1_VerifyEmail() { + email := e.uuid() + "email@email.com" + password := "qwerty" + + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/auth/signup", + e.jsonify(apiv1AuthSignUpRequest{ + Username: e.uuid(), + Email: email, + Password: password, + }), + ) + + e.Equal(http.StatusCreated, httpResp.Code) + + // TODO: probably should get the token from the email + + user := e.getLastInsertedUserByEmail(email) + token := e.getVerificationTokenByUserID(user.ID) + httpResp = e.httpRequest(http.MethodGet, "/api/v1/auth/verify/"+token.Token, nil) + e.Equal(http.StatusOK, httpResp.Code) + + user = e.getLastInsertedUserByEmail(email) + e.Equal(user.Activated, true) +} + +func (e *AppTestSuite) TestAuthV1_ResendVerificationEmail() { + email, password := e.uuid()+"email@email.com", e.uuid() + + // create test user + signUpHTTPResp := e.httpRequest( + http.MethodPost, + "/api/v1/auth/signup", + e.jsonify(apiv1AuthSignUpRequest{ + Username: e.uuid(), + Email: email, + Password: password, + }), + ) + + e.Equal(http.StatusCreated, signUpHTTPResp.Code) + + // handle sending of the email + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/auth/resend-verification-email", + e.jsonify(apiv1AuthSignInRequest{ + Email: email, + Password: password, + }), + ) + + e.Equal(http.StatusOK, httpResp.Code) + e.NotEmpty(e.mailer.GetLastSentEmailToEmail(email)) +} + +func (e *AppTestSuite) TestAuthV1_ResendVerificationEmail_wrong() { + email, password := e.uuid()+"@"+e.uuid()+".com", "password" + e.insertUserIntoDB(e.uuid(), email, password, true) + + tests := []struct { + name string + email string + password string + expectedCode int + }{ + { + name: "activated account", + email: email, + password: password, + expectedCode: http.StatusBadRequest, + }, + { + name: "wrong credintials", + email: email, + password: e.uuid(), + expectedCode: http.StatusUnauthorized, + }, + } + + for _, t := range tests { + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/auth/resend-verification-email", + e.jsonify(apiv1AuthSignInRequest{ + Email: t.email, + Password: t.password, + })) + + e.Equal(httpResp.Code, t.expectedCode) + + // no email should be sent + e.Empty(e.mailer.GetLastSentEmailToEmail(t.email)) + } +} func (e *AppTestSuite) TestAuthV1_SignIn() { email := e.uuid() + "email@email.com" password := "qwerty" - uid := e.insertUserIntoDB("test", email, password) + uid := e.insertUserIntoDB("test", email, password, true) httpResp := e.httpRequest( http.MethodPost,

@@ -111,22 +209,39 @@

func (e *AppTestSuite) TestAuthV1_SignIn_wrong() { password := "password" email := e.uuid() + "@test.com" - e.insertUserIntoDB(e.uuid(), email, "password") + e.insertUserIntoDB(e.uuid(), email, "password", true) + + unactivatedEmail := e.uuid() + "@test.com" + e.insertUserIntoDB(e.uuid(), unactivatedEmail, password, false) tests := []struct { - name string - email string - password string + name string + email string + password string + expectedCode int + + expectMsg bool + expectedMsg string }{ { - name: "wrong email", - email: "wrong@emai.com", - password: password, + name: "unactivated user", + email: unactivatedEmail, + password: password, + expectedCode: http.StatusBadRequest, + expectMsg: true, + expectedMsg: models.ErrUserIsNotActivated.Error(), + }, + { + name: "wrong email", + email: "wrong@emai.com", + password: password, + expectedCode: http.StatusUnauthorized, }, { - name: "wrong password", - email: email, - password: "wrong-wrong", + name: "wrong password", + email: email, + password: "wrong-wrong", + expectedCode: http.StatusUnauthorized, }, }

@@ -140,7 +255,14 @@ Password: t.password,

}), ) - e.Equal(http.StatusUnauthorized, httpResp.Code) + if t.expectMsg { + var body errorResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + e.Equal(body.Message, t.expectedMsg) + } + + e.Equal(t.expectedCode, httpResp.Code) } }

@@ -161,16 +283,17 @@

var body apiv1AuthSignInResponse e.readBodyAndUnjsonify(httpResp.Body, &body) - session := e.getLastUserSessionByUserID(uid) - parsedToken := e.parseJwtToken(body.AccessToken) - e.Equal(parsedToken.UserID, uid.String()) + sessionDB := e.getLastUserSessionByUserID(uid) + e.Equal(e.parseJwtToken(body.AccessToken).UserID, uid.String()) e.Equal(httpResp.Code, http.StatusOK) e.NotEqual(toks.RefreshToken, body.RefreshToken) - e.Equal(body.RefreshToken, session.RefreshToken) + e.Equal(body.RefreshToken, sessionDB.RefreshToken) } func (e *AppTestSuite) TestAuthV1_RefreshTokens_wrong() { + // requests a new token pair with a wrong refresh token + httpResp := e.httpRequest( http.MethodPost, "/api/v1/auth/refresh-tokens",

@@ -185,21 +308,50 @@

func (e *AppTestSuite) TestAuthV1_Logout() { uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password") - session := e.getLastUserSessionByUserID(uid) - e.NotEmpty(session.RefreshToken) + sessionDB := e.getLastUserSessionByUserID(uid) + e.NotEmpty(sessionDB.RefreshToken) httpResp := e.httpRequest(http.MethodPost, "/api/v1/auth/logout", nil, toks.AccessToken) + e.Equal(httpResp.Code, http.StatusNoContent) - e.Equal(httpResp.Code, http.StatusNoContent) + sessionDB = e.getLastUserSessionByUserID(uid) + e.Empty(sessionDB.RefreshToken) +} + +type apiv1AtuhChangePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +func (e *AppTestSuite) TestAuthV1_ChangePassword() { + password := e.uuid() + newPassword := e.uuid() + username := e.uuid() + _, toks := e.createAndSingIn(e.uuid()+"@test.com", username, password) - session = e.getLastUserSessionByUserID(uid) - e.Empty(session.RefreshToken) + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/auth/change-password", + e.jsonify(apiv1AtuhChangePasswordRequest{ + CurrentPassword: password, + NewPassword: newPassword, + }), + toks.AccessToken, + ) + + e.Equal(httpResp.Code, http.StatusOK) + + userDB := e.getUserFromDBByUsername(username) + hashedNewPassword, err := e.hasher.Hash(newPassword) + e.require.NoError(err) + + e.Equal(userDB.Password, hashedNewPassword) } func (e *AppTestSuite) createAndSingIn( email, username, password string, ) (uuid.UUID, apiv1AuthSignInResponse) { - uid := e.insertUserIntoDB(username, email, password) + uid := e.insertUserIntoDB(username, email, password, true) httpResp := e.httpRequest( http.MethodPost, "/api/v1/auth/signin",
M e2e/e2e_test.go

@@ -3,7 +3,9 @@

import ( "context" "fmt" + "log/slog" "net/http" + "os" "testing" "time"

@@ -11,13 +13,16 @@ "github.com/gin-gonic/gin"

"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/pgx" "github.com/jackc/pgx/v5/stdlib" + "github.com/olexsmir/onasty/internal/config" "github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/jwtutil" + "github.com/olexsmir/onasty/internal/mailer" "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" "github.com/olexsmir/onasty/internal/store/psql/noterepo" "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" "github.com/olexsmir/onasty/internal/store/psql/userepo" + "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" "github.com/olexsmir/onasty/internal/store/psqlutil" httptransport "github.com/olexsmir/onasty/internal/transport/http" "github.com/stretchr/testify/require"

@@ -43,6 +48,10 @@

router http.Handler hasher hasher.Hasher jwtTokenizer jwtutil.JWTTokenizer + mailer *mailer.TestMailer + } + errorResponse struct { + Message string `json:"message"` } )

@@ -62,11 +71,12 @@ e.ctx = context.Background()

e.require = e.Require() db, stop, err := e.prepPostgres() - e.Require().NoError(err) + e.require.NoError(err) e.postgresDB = db e.stopPostgres = stop + e.setupLogger() e.initDeps() }

@@ -77,13 +87,26 @@

// initDeps initializes the dependencies for the app // and sets up the router for tests func (e *AppTestSuite) initDeps() { - e.hasher = hasher.NewSHA256Hasher("pass_salt") - e.jwtTokenizer = jwtutil.NewJWTUtil("jwt", time.Hour) + cfg := e.getConfig() + + e.hasher = hasher.NewSHA256Hasher(cfg.PasswordSalt) + e.jwtTokenizer = jwtutil.NewJWTUtil(cfg.JwtSigningKey, time.Hour) + e.mailer = mailer.NewTestMailer() sessionrepo := sessionrepo.New(e.postgresDB) + vertokrepo := vertokrepo.New(e.postgresDB) userepo := userepo.New(e.postgresDB) - usersrv := usersrv.New(userepo, sessionrepo, e.hasher, e.jwtTokenizer) + usersrv := usersrv.New( + userepo, + sessionrepo, + vertokrepo, + e.hasher, + e.jwtTokenizer, + e.mailer, + cfg.JwtRefreshTokenTTL, + cfg.VerficationTokenTTL, + ) noterepo := noterepo.New(e.postgresDB) notesrv := notesrv.New(noterepo)

@@ -143,3 +166,22 @@ e.require.NoError(err)

return db, stop, driver.Close() } + +func (e *AppTestSuite) setupLogger() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + AddSource: os.Getenv("LOG_SHOW_LINE") == "true", + }))) +} + +func (e *AppTestSuite) getConfig() *config.Config { + return &config.Config{ + AppEnv: "testing", + ServerPort: "3000", + PasswordSalt: "salty-password", + JwtSigningKey: "jwt-key", + JwtAccessTokenTTL: time.Hour, + JwtRefreshTokenTTL: 24 * time.Hour, + VerficationTokenTTL: 24 * time.Hour, + } +}
M e2e/e2e_utils_db_test.go

@@ -14,9 +14,7 @@ func (e *AppTestSuite) getUserFromDBByUsername(username string) models.User {

query, args, err := pgq. Select("id", "username", "email", "password", "created_at", "last_login_at"). From("users"). - Where(pgq.Eq{ - "username": username, - }). + Where(pgq.Eq{"username": username}). SQL() e.require.NoError(err)

@@ -28,14 +26,19 @@

return user } -func (e *AppTestSuite) insertUserIntoDB(uname, email, passwd string) uuid.UUID { +func (e *AppTestSuite) insertUserIntoDB(uname, email, passwd string, activated ...bool) uuid.UUID { p, err := e.hasher.Hash(passwd) e.require.NoError(err) + var a bool + if len(activated) == 1 { + a = activated[0] + } + query, args, err := pgq. Insert("users"). Columns("username", "email", "password", "activated", "created_at", "last_login_at"). - Values(uname, email, p, true, time.Now(), time.Now()). + Values(uname, email, p, a, time.Now(), time.Now()). Returning("id"). SQL() e.require.NoError(err)

@@ -59,7 +62,7 @@

var session models.Session err = e.postgresDB.QueryRow(e.ctx, query, args...). Scan(&session.RefreshToken, &session.ExpiresAt) - if errors.Is(pgx.ErrNoRows, err) { + if errors.Is(err, pgx.ErrNoRows) { return models.Session{} }

@@ -67,11 +70,32 @@ e.require.NoError(err)

return session } +func (e *AppTestSuite) getLastInsertedUserByEmail(em string) models.User { + query, args, err := pgq. + Select("id", "username", "activated", "email", "password"). + From("users"). + Where(pgq.Eq{"email": em}). + OrderBy("created_at DESC"). + Limit(1). + SQL() + e.require.NoError(err) + + var u models.User + err = e.postgresDB.QueryRow(e.ctx, query, args...). + Scan(&u.ID, &u.Username, &u.Activated, &u.Email, &u.Password) + if errors.Is(err, pgx.ErrNoRows) { + return models.User{} + } + + e.require.NoError(err) + return u +} + func (e *AppTestSuite) getNoteFromDBbySlug(slug string) models.Note { query, args, err := pgq. Select("id", "content", "slug", "burn_before_expiration", "created_at", "expires_at"). From("notes"). - Where("slug = ?", slug). + Where(pgq.Eq{"slug": slug}). SQL() e.require.NoError(err)

@@ -110,3 +134,21 @@

e.require.NoError(err) return na } + +type userVerificationToken struct { + Token string + UsedAt *time.Time +} + +func (e *AppTestSuite) getVerificationTokenByUserID(u uuid.UUID) userVerificationToken { + query, args, err := pgq. + Select("token", "used_at"). + From("verification_tokens"). + Where(pgq.Eq{"user_id": u.String()}). + SQL() + e.require.NoError(err) + var r userVerificationToken + err = e.postgresDB.QueryRow(e.ctx, query, args...).Scan(&r.Token, &r.UsedAt) + e.require.NoError(err) + return r +}
M go.mod

@@ -8,9 +8,9 @@ github.com/gofrs/uuid/v5 v5.3.0

github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-migrate/migrate/v4 v4.17.1 github.com/henvic/pgq v0.0.2 - github.com/jackc/pgconn v1.14.3 github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 github.com/jackc/pgx/v5 v5.6.0 + github.com/mailgun/mailgun-go/v4 v4.12.0 github.com/stretchr/testify v1.9.0 github.com/testcontainers/testcontainers-go v0.33.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0

@@ -37,6 +37,7 @@ github.com/docker/go-units v0.5.0 // indirect

github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-chi/chi/v5 v5.0.8 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect

@@ -49,6 +50,7 @@ github.com/google/uuid v1.6.0 // indirect

github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgconn v1.14.3 // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
M go.sum

@@ -47,6 +47,12 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=

github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ= +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y= +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=

@@ -55,6 +61,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=

github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= +github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=

@@ -157,6 +165,7 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=

github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=

@@ -189,6 +198,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=

github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailgun/mailgun-go/v4 v4.12.0 h1:TtuQCgqSp4cB6swPxP5VF/u4JeeBIAjTdpuQ+4Usd/w= +github.com/mailgun/mailgun-go/v4 v4.12.0/go.mod h1:L9s941Lgk7iB3TgywTPz074pK2Ekkg4kgbnAaAyJ2z8= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=

@@ -209,6 +220,7 @@ github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=

github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
M internal/config/config.go

@@ -1,6 +1,7 @@

package config import ( + "errors" "os" "time" )

@@ -8,29 +9,48 @@

type Config struct { AppEnv string ServerPort string + PostgresDSN string PasswordSalt string JwtSigningKey string JwtAccessTokenTTL time.Duration JwtRefreshTokenTTL time.Duration - LogLevel string - LogFormat string + MailgunFrom string + MailgunDomain string + MailgunAPIKey string + VerficationTokenTTL time.Duration - PostgresDSN string + LogLevel string + LogFormat string + LogShowLine bool } func NewConfig() *Config { return &Config{ - AppEnv: getenvOrDefault("APP_ENV", "debug"), - ServerPort: getenvOrDefault("SERVER_PORT", "3000"), - PasswordSalt: getenvOrDefault("PASSWORD_SALT", ""), - JwtSigningKey: getenvOrDefault("JWT_SIGNING_KEY", ""), - JwtAccessTokenTTL: mustParseDuration(getenvOrDefault("JWT_ACCESS_TOKEN_TTL", "15m")), - JwtRefreshTokenTTL: mustParseDuration(getenvOrDefault("JWT_REFRESH_TOKEN_TTL", "15d")), - LogLevel: getenvOrDefault("LOG_LEVEL", "debug"), - LogFormat: getenvOrDefault("LOG_FORMAT", "json"), - PostgresDSN: getenvOrDefault("POSTGRESQL_DSN", ""), + AppEnv: getenvOrDefault("APP_ENV", "debug"), + ServerPort: getenvOrDefault("SERVER_PORT", "3000"), + PostgresDSN: getenvOrDefault("POSTGRESQL_DSN", ""), + PasswordSalt: getenvOrDefault("PASSWORD_SALT", ""), + + JwtSigningKey: getenvOrDefault("JWT_SIGNING_KEY", ""), + JwtAccessTokenTTL: mustParseDurationOrPanic( + getenvOrDefault("JWT_ACCESS_TOKEN_TTL", "15m"), + ), + JwtRefreshTokenTTL: mustParseDurationOrPanic( + getenvOrDefault("JWT_REFRESH_TOKEN_TTL", "24h"), + ), + + MailgunFrom: getenvOrDefault("MAILGUN_FROM", ""), + MailgunDomain: getenvOrDefault("MAILGUN_DOMAIN", ""), + MailgunAPIKey: getenvOrDefault("MAILGUN_API_KEY", ""), + VerficationTokenTTL: mustParseDurationOrPanic( + getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h"), + ), + + LogLevel: getenvOrDefault("LOG_LEVEL", "debug"), + LogFormat: getenvOrDefault("LOG_FORMAT", "json"), + LogShowLine: getenvOrDefault("LOG_SHOW_LINE", "true") == "true", } }

@@ -45,7 +65,11 @@ }

return def } -func mustParseDuration(dur string) time.Duration { - d, _ := time.ParseDuration(dur) +func mustParseDurationOrPanic(dur string) time.Duration { + d, err := time.ParseDuration(dur) + if err != nil { + panic(errors.Join(errors.New("cannot time.ParseDuration"), err)) + } + return d }
M internal/dtos/user.go

@@ -11,8 +11,16 @@ ID uuid.UUID

Username string Email string Password string + Activated bool CreatedAt time.Time LastLoginAt time.Time +} + +type ResetUserPasswordDTO struct { + // NOTE: probablbe userID shouldn't be here + UserID uuid.UUID + CurrentPassword string + NewPassword string } type CreateUserDTO struct {
A internal/mailer/mailer.go

@@ -0,0 +1,7 @@

+package mailer + +import "context" + +type Mailer interface { + Send(ctx context.Context, to, subject, content string) error +}
A internal/mailer/mailgun.go

@@ -0,0 +1,36 @@

+package mailer + +import ( + "context" + "log/slog" + + "github.com/mailgun/mailgun-go/v4" +) + +var _ Mailer = (*Mailgun)(nil) + +type Mailgun struct { + from string + + mg *mailgun.MailgunImpl +} + +func NewMailgun(from, domain, apiKey string) *Mailgun { + mg := mailgun.NewMailgun(domain, apiKey) + return &Mailgun{ + from: from, + mg: mg, + } +} + +func (m *Mailgun) Send(ctx context.Context, to, subject, content string) error { + msg := m.mg.NewMessage(m.from, subject, "", to) + msg.SetHtml(content) + + _, _, err := m.mg.Send(ctx, msg) + + slog.Info("email sent", "to", to) + slog.Debug("email sent", "subject", subject, "content", content, "err", err) + + return err +}
A internal/mailer/testing_mailer.go

@@ -0,0 +1,28 @@

+package mailer + +import "context" + +var _ Mailer = (*TestMailer)(nil) + +type TestMailer struct { + emails map[string]string +} + +// NewTestMailer create a mailer for tests +// that implementation of Mailer stores all sent email in memory +// to get the last email sent to a specific email use GetLastSentEmailToEmail +func NewTestMailer() *TestMailer { + return &TestMailer{ + emails: make(map[string]string), + } +} + +func (t *TestMailer) Send(_ context.Context, to, _, content string) error { + t.emails[to] = content + return nil +} + +// GetLastSentEmailToEmail returns the last email sent to a specific email +func (t *TestMailer) GetLastSentEmailToEmail(email string) string { + return t.emails[email] +}
A internal/mailer/testing_mailer_test.go

@@ -0,0 +1,33 @@

+package mailer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMailer_Send(t *testing.T) { + m := NewTestMailer() + assert.Empty(t, m.emails) + + email := "test@mail.com" + err := m.Send(context.TODO(), email, "", "content") + require.NoError(t, err) + + assert.Equal(t, "content", m.emails[email]) +} + +func TestMailer_GetLastSentEmailToEmail(t *testing.T) { + m := NewTestMailer() + assert.Empty(t, m.emails) + + email := "test@mail.com" + content := "content" + err := m.Send(context.TODO(), email, "", content) + require.NoError(t, err) + + c := m.GetLastSentEmailToEmail(email) + assert.Equal(t, content, c) +}
M internal/models/user.go

@@ -11,6 +11,10 @@

var ( ErrUserEmailIsAlreadyInUse = errors.New("user: email is already in use") ErrUsernameIsAlreadyInUse = errors.New("user: username is already in use") + ErrUserIsAlreeadyVerified = errors.New("user: user is already verified") + + ErrVerificationTokenNotFound = errors.New("user: verification token not found") + ErrUserIsNotActivated = errors.New("user: user is not activated") ErrUserNotFound = errors.New("user: not found") ErrUserWrongCredentials = errors.New("user: wrong credentials")

@@ -20,6 +24,7 @@ type User struct {

ID uuid.UUID Username string Email string + Activated bool Password string CreatedAt time.Time LastLoginAt time.Time
A internal/service/usersrv/email.go

@@ -0,0 +1,47 @@

+package usersrv + +import ( + "context" + "errors" + "fmt" + "log/slog" +) + +var ErrFailedToSendVerifcationEmail = errors.New("failed to send verification email") + +const ( + verificationEmailSubject = "Onasty: verifiy your email" + verificationEmailBody = `To verify your email, please follow this link: +<a href="%[1]s/api/v1/auth/verify/%[2]s">%[1]s/api/v1/auth/verify/%[2]s</a> +<br /> +<br /> +This link will expire after 24 hours.` +) + +func (u *UserSrv) sendVerificationEmail( + ctx context.Context, + cancel context.CancelFunc, + userEmail string, + token string, +) error { + select { + case <-ctx.Done(): + slog.Error("failed to send verfication email", "err", ctx.Err()) + return ErrFailedToSendVerifcationEmail + default: + if err := u.mailer.Send( + ctx, + userEmail, + verificationEmailSubject, + // TODO: set proper url + fmt.Sprintf(verificationEmailBody, "http://localhost:3000", token), + ); err != nil { + return errors.Join(ErrFailedToSendVerifcationEmail, err) + } + cancel() + + slog.Debug("email sent") + } + + return nil +}
M internal/service/usersrv/usersrv.go

@@ -9,9 +9,11 @@ "github.com/gofrs/uuid/v5"

"github.com/olexsmir/onasty/internal/dtos" "github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/jwtutil" + "github.com/olexsmir/onasty/internal/mailer" "github.com/olexsmir/onasty/internal/models" "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" "github.com/olexsmir/onasty/internal/store/psql/userepo" + "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" ) type UserServicer interface {

@@ -20,8 +22,15 @@ SignIn(ctx context.Context, inp dtos.SignInDTO) (dtos.TokensDTO, error)

RefreshTokens(ctx context.Context, refreshToken string) (dtos.TokensDTO, error) Logout(ctx context.Context, userID uuid.UUID) error - ParseToken(token string) (jwtutil.Payload, error) + ChangePassword(ctx context.Context, inp dtos.ResetUserPasswordDTO) error + + Verify(ctx context.Context, verificationKey string) error + ResendVerificationEmail(ctx context.Context, credentials dtos.SignInDTO) error + + ParseJWTToken(token string) (jwtutil.Payload, error) + CheckIfUserExists(ctx context.Context, userID uuid.UUID) (bool, error) + CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error) } var _ UserServicer = (*UserSrv)(nil)

@@ -29,23 +38,33 @@

type UserSrv struct { userstore userepo.UserStorer sessionstore sessionrepo.SessionStorer + vertokrepo vertokrepo.VerificationTokenStorer hasher hasher.Hasher jwtTokenizer jwtutil.JWTTokenizer + mailer mailer.Mailer - refreshTokenExpiredAt time.Time + refreshTokenTTL time.Duration + verificationTokenTTL time.Duration } func New( userstore userepo.UserStorer, sessionstore sessionrepo.SessionStorer, + vertokrepo vertokrepo.VerificationTokenStorer, hasher hasher.Hasher, jwtTokenizer jwtutil.JWTTokenizer, + mailer mailer.Mailer, + refreshTokenTTL, verificationTokenTTL time.Duration, ) UserServicer { return &UserSrv{ - userstore: userstore, - sessionstore: sessionstore, - hasher: hasher, - jwtTokenizer: jwtTokenizer, + userstore: userstore, + sessionstore: sessionstore, + vertokrepo: vertokrepo, + hasher: hasher, + jwtTokenizer: jwtTokenizer, + mailer: mailer, + refreshTokenTTL: refreshTokenTTL, + verificationTokenTTL: verificationTokenTTL, } }

@@ -55,13 +74,28 @@ if err != nil {

return uuid.UUID{}, err } - return u.userstore.Create(ctx, dtos.CreateUserDTO{ + uid, err := u.userstore.Create(ctx, dtos.CreateUserDTO{ Username: inp.Username, Email: inp.Email, Password: hashedPassword, CreatedAt: inp.CreatedAt, LastLoginAt: inp.LastLoginAt, }) + if err != nil { + return uuid.Nil, err + } + + vtok := uuid.Must(uuid.NewV4()).String() + if err := u.vertokrepo.Create(ctx, vtok, uid, time.Now(), time.Now().Add(u.verificationTokenTTL)); err != nil { + return uuid.Nil, err + } + + // TODO: handle the error that might be returned + // i dont think that tehre's need to handle the error, just log it + bgCtx, bgCancel := context.WithTimeout(context.Background(), 10*time.Second) + go u.sendVerificationEmail(bgCtx, bgCancel, inp.Email, vtok) //nolint:errcheck + + return uid, nil } func (u *UserSrv) SignIn(ctx context.Context, inp dtos.SignInDTO) (dtos.TokensDTO, error) {

@@ -78,12 +112,16 @@ }

return dtos.TokensDTO{}, err } + if !user.Activated { + return dtos.TokensDTO{}, models.ErrUserIsNotActivated + } + tokens, err := u.getTokens(user.ID) if err != nil { return dtos.TokensDTO{}, err } - if err := u.sessionstore.Set(ctx, user.ID, tokens.Refresh, u.refreshTokenExpiredAt); err != nil { + if err := u.sessionstore.Set(ctx, user.ID, tokens.Refresh, time.Now().Add(u.refreshTokenTTL)); err != nil { return dtos.TokensDTO{}, err }

@@ -108,20 +146,86 @@ if err != nil {

return dtos.TokensDTO{}, err } - err = u.sessionstore.Update(ctx, userID, rtoken, tokens.Refresh) + if err := u.sessionstore.Update(ctx, userID, rtoken, tokens.Refresh); err != nil { + return dtos.TokensDTO{}, err + } return dtos.TokensDTO{ Access: tokens.Access, Refresh: tokens.Refresh, - }, err + }, nil } -func (u *UserSrv) ParseToken(token string) (jwtutil.Payload, error) { +func (u *UserSrv) ChangePassword(ctx context.Context, inp dtos.ResetUserPasswordDTO) error { + oldPass, err := u.hasher.Hash(inp.CurrentPassword) + if err != nil { + return err + } + + newPass, err := u.hasher.Hash(inp.NewPassword) + if err != nil { + return err + } + + if err := u.userstore.ChangePassword(ctx, inp.UserID, oldPass, newPass); err != nil { + return err + } + + return nil +} + +func (u *UserSrv) Verify(ctx context.Context, verificationKey string) error { + uid, err := u.vertokrepo.GetUserIDByTokenAndMarkAsUsed(ctx, verificationKey, time.Now()) + if err != nil { + return err + } + + return u.userstore.MarkUserAsActivated(ctx, uid) +} + +func (u *UserSrv) ResendVerificationEmail(ctx context.Context, inp dtos.SignInDTO) error { + hashedPassword, err := u.hasher.Hash(inp.Password) + if err != nil { + return err + } + + user, err := u.userstore.GetUserByCredentials(ctx, inp.Email, hashedPassword) + if err != nil { + if errors.Is(err, models.ErrUserNotFound) { + return models.ErrUserWrongCredentials + } + return err + } + + if user.Activated { + return models.ErrUserIsAlreeadyVerified + } + + token, err := u.vertokrepo.GetTokenOrUpdateTokenByUserID( + ctx, + user.ID, + uuid.Must(uuid.NewV4()).String(), + time.Now().Add(u.verificationTokenTTL)) + if err != nil { + return err + } + + bgCtx, bgCancel := context.WithTimeout(context.Background(), 10*time.Second) + go u.sendVerificationEmail(bgCtx, bgCancel, inp.Email, token) //nolint:errcheck + + return nil +} + +func (u *UserSrv) ParseJWTToken(token string) (jwtutil.Payload, error) { return u.jwtTokenizer.Parse(token) } func (u UserSrv) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) { return u.userstore.CheckIfUserExists(ctx, id) +} + +func (u UserSrv) CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error) { + return u.userstore.CheckIfUserIsActivated(ctx, userID) } func (u UserSrv) getTokens(userID uuid.UUID) (dtos.TokensDTO, error) {
M internal/store/psql/sessionrepo/sessionrepo.go

@@ -62,7 +62,7 @@ set refresh_token = $1

where user_id = $2 and refresh_token = $3 - and expires_at < now() + -- and expires_at < now() ` res, err := s.db.Exec(ctx, query, newRefreshToken, userID, refreshToken)
M internal/store/psql/userepo/userepo.go

@@ -14,9 +14,24 @@ )

type UserStorer interface { Create(ctx context.Context, inp dtos.CreateUserDTO) (uuid.UUID, error) + + // GetUserByCredentials returns user by email and password + // password should be hashed GetUserByCredentials(ctx context.Context, email, password string) (dtos.UserDTO, error) - CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) + GetUserIDByEmail(ctx context.Context, email string) (uuid.UUID, error) + MarkUserAsActivated(ctx context.Context, id uuid.UUID) error + + // ChangePassword changes user password from oldPassword to newPassword + // and oldPassword and newPassword should be hashed + ChangePassword(ctx context.Context, userID uuid.UUID, oldPassword, newPassword string) error + + // SetPassword sets new password for user by their id + // password should be hashed + SetPassword(ctx context.Context, userID uuid.UUID, newPassword string) error + + CheckIfUserExists(ctx context.Context, userID uuid.UUID) (bool, error) + CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error) } var _ UserStorer = (*UserRepo)(nil)

@@ -62,7 +77,7 @@ ctx context.Context,

email, password string, ) (dtos.UserDTO, error) { query, args, err := pgq. - Select("id", "username", "email", "password", "created_at", "last_login_at"). + Select("id", "username", "email", "password", "activated", "created_at", "last_login_at"). From("users"). Where(pgq.Eq{ "email": email,

@@ -75,7 +90,7 @@ }

var user dtos.UserDTO err = r.db.QueryRow(ctx, query, args...). - Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.LastLoginAt) + Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.Activated, &user.CreatedAt, &user.LastLoginAt) if errors.Is(err, pgx.ErrNoRows) { return dtos.UserDTO{}, models.ErrUserNotFound }

@@ -83,6 +98,73 @@

return user, err } +func (r *UserRepo) GetUserIDByEmail(ctx context.Context, email string) (uuid.UUID, error) { + query, args, err := pgq. + Select("id"). + From("users"). + Where(pgq.Eq{"email": email}). + SQL() + if err != nil { + return uuid.Nil, err + } + + var id uuid.UUID + err = r.db.QueryRow(ctx, query, args...).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, models.ErrUserNotFound + } + + return id, err +} + +func (r *UserRepo) MarkUserAsActivated(ctx context.Context, id uuid.UUID) error { + query, args, err := pgq. + Update("users"). + Set("activated ", true). + Where(pgq.Eq{"id": id.String()}). + SQL() + if err != nil { + return err + } + + _, err = r.db.Exec(ctx, query, args...) + return err +} + +func (r *UserRepo) ChangePassword( + ctx context.Context, + userID uuid.UUID, + oldPass, newPass string, +) error { + query, args, err := pgq. + Update("users"). + Set("password", newPass). + Where(pgq.Eq{ + "id": userID.String(), + "password": oldPass, + }). + SQL() + if err != nil { + return err + } + _, err = r.db.Exec(ctx, query, args...) + return err +} + +func (r *UserRepo) SetPassword(ctx context.Context, userID uuid.UUID, password string) error { + query, args, err := pgq. + Update("users"). + Set("password", password). + Where(pgq.Eq{"id": userID.String()}). + SQL() + if err != nil { + return err + } + + _, err = r.db.Exec(ctx, query, args...) + return err +} + func (r *UserRepo) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) { var exists bool err := r.db.QueryRow(

@@ -96,3 +178,13 @@ }

return exists, err } + +func (r *UserRepo) CheckIfUserIsActivated(ctx context.Context, id uuid.UUID) (bool, error) { + var activated bool + err := r.db.QueryRow(ctx, `SELECT activated FROM users WHERE id = $1`, id.String()). + Scan(&activated) + if errors.Is(err, pgx.ErrNoRows) { + return false, models.ErrUserNotFound + } + return activated, err +}
A internal/store/psql/vertokrepo/vertokrepo.go

@@ -0,0 +1,121 @@

+package vertokrepo + +import ( + "context" + "time" + + "github.com/gofrs/uuid/v5" + "github.com/henvic/pgq" + "github.com/olexsmir/onasty/internal/models" + "github.com/olexsmir/onasty/internal/store/psqlutil" +) + +type VerificationTokenStorer interface { + Create( + ctx context.Context, + token string, + userID uuid.UUID, + createdAt, expiresAt time.Time, + ) error + + GetUserIDByTokenAndMarkAsUsed( + ctx context.Context, + token string, + usedAT time.Time, + ) (uuid.UUID, error) + + GetTokenOrUpdateTokenByUserID( + ctx context.Context, + userID uuid.UUID, + token string, + tokenExpirationTime time.Time, + ) (string, error) +} + +var _ VerificationTokenStorer = (*VerificationTokenRepo)(nil) + +type VerificationTokenRepo struct { + db *psqlutil.DB +} + +func New(db *psqlutil.DB) *VerificationTokenRepo { + return &VerificationTokenRepo{ + db: db, + } +} + +func (r *VerificationTokenRepo) Create( + ctx context.Context, + token string, + userID uuid.UUID, + createdAt, expiresAt time.Time, +) error { + query, aggs, err := pgq. + Insert("verification_tokens"). + Columns("user_id", "token", "created_at", "expires_at"). + Values(userID, token, createdAt, expiresAt). + SQL() + if err != nil { + return err + } + + _, err = r.db.Exec(ctx, query, aggs...) + return err +} + +func (r *VerificationTokenRepo) GetUserIDByTokenAndMarkAsUsed( + ctx context.Context, + token string, + usedAt time.Time, +) (uuid.UUID, error) { + tx, err := r.db.Begin(ctx) + if err != nil { + return uuid.Nil, err + } + defer tx.Rollback(ctx) //nolint:errcheck + + var isUsed bool + err = tx.QueryRow(ctx, "select used_at is not null from verification_tokens where token = $1", token). + Scan(&isUsed) + if err != nil { + return uuid.Nil, err + } + + if isUsed { + return uuid.Nil, models.ErrUserIsAlreeadyVerified + } + + query := `--sql +update verification_tokens +set used_at = $1 +where token = $2 +returning user_id` + + var userID uuid.UUID + err = tx.QueryRow(ctx, query, usedAt, token).Scan(&userID) + if err != nil { + return uuid.Nil, err + } + + return userID, tx.Commit(ctx) +} + +func (r *VerificationTokenRepo) GetTokenOrUpdateTokenByUserID( + ctx context.Context, + userID uuid.UUID, + token string, + tokenExpirationTime time.Time, +) (string, error) { + query := `--sql +insert into verification_tokens (user_id, token, expires_at) +values ($1, $2, $3) +on conflict (user_id) + do update set + token = $2, + expires_at = $3 +returning token` + + var res string + err := r.db.QueryRow(ctx, query, userID, token, tokenExpirationTime).Scan(&res) + return res, err +}
M internal/store/psqlutil/psqlutil.go

@@ -4,9 +4,9 @@ import (

"context" "errors" - "github.com/jackc/pgconn" pgxuuid "github.com/jackc/pgx-gofrs-uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" )
M internal/transport/http/apiv1/apiv1.go

@@ -27,10 +27,13 @@ {

auth.POST("/signup", a.signUpHandler) auth.POST("/signin", a.signInHandler) auth.POST("/refresh-tokens", a.refreshTokensHandler) + auth.GET("/verify/:token", a.verifyHandler) + auth.POST("/resend-verification-email", a.resendVerificationEmailHandler) authorized := auth.Group("/", a.authorizedMiddleware) { authorized.POST("/logout", a.logOutHandler) + authorized.POST("/change-password", a.changePasswordHandler) } }
M internal/transport/http/apiv1/auth.go

@@ -104,6 +104,33 @@ RefreshToken: toks.Refresh,

}) } +func (a *APIV1) verifyHandler(c *gin.Context) { + if err := a.usersrv.Verify(c.Request.Context(), c.Param("token")); err != nil { + errorResponse(c, err) + return + } + + c.String(http.StatusOK, "email verified") +} + +func (a *APIV1) resendVerificationEmailHandler(c *gin.Context) { + var req signInRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + if err := a.usersrv.ResendVerificationEmail(c.Request.Context(), dtos.SignInDTO{ + Email: req.Email, + Password: req.Password, + }); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +} + func (a *APIV1) logOutHandler(c *gin.Context) { if err := a.usersrv.Logout(c.Request.Context(), a.getUserID(c)); err != nil { errorResponse(c, err)

@@ -112,3 +139,29 @@ }

c.Status(http.StatusNoContent) } + +type changePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +func (a *APIV1) changePasswordHandler(c *gin.Context) { + var req changePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + if err := a.usersrv.ChangePassword( + c.Request.Context(), + dtos.ResetUserPasswordDTO{ + UserID: a.getUserID(c), + CurrentPassword: req.CurrentPassword, + NewPassword: req.NewPassword, + }); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +}
M internal/transport/http/apiv1/middleware.go

@@ -2,18 +2,19 @@ package apiv1

import ( "context" - "errors" "strings" "github.com/gin-gonic/gin" "github.com/gofrs/uuid/v5" - "github.com/olexsmir/onasty/internal/service/usersrv" + "github.com/olexsmir/onasty/internal/models" ) -var ErrUnauthorized = errors.New("unauthorized") - const userIDCtxKey = "userID" +// authorizedMiddleware is a middleware that checks if user is authorized +// and if so sets user metadata to context +// +// being authorized is required for making the request for specific endpoint func (a *APIV1) authorizedMiddleware(c *gin.Context) { token, ok := getTokenFromAuthHeaders(c) if !ok {

@@ -21,43 +22,31 @@ errorResponse(c, ErrUnauthorized)

return } - ok, err := checkIfUserIsReal(c.Request.Context(), token, a.usersrv) + uid, err := a.validateAuthorizedUser(c.Request.Context(), token) if err != nil { errorResponse(c, err) return } - if !ok { - errorResponse(c, ErrUnauthorized) - return - } - - if err := saveUserIDToCtx(c, a.usersrv, token); err != nil { - errorResponse(c, err) - return - } + c.Set(userIDCtxKey, uid) c.Next() } +// couldBeAuthorizedMiddleware is a middleware that checks if user is authorized and +// if so sets user metadata to context +// +// it is NOT required to be authorized for making the request for specific endpoint func (a *APIV1) couldBeAuthorizedMiddleware(c *gin.Context) { token, ok := getTokenFromAuthHeaders(c) if ok { - ok, err := checkIfUserIsReal(c.Request.Context(), token, a.usersrv) + uid, err := a.validateAuthorizedUser(c.Request.Context(), token) if err != nil { errorResponse(c, err) return } - if !ok { - errorResponse(c, ErrUnauthorized) - return - } - - if err := saveUserIDToCtx(c, a.usersrv, token); err != nil { - newInternalError(c, err) - return - } + c.Set(userIDCtxKey, uid) } c.Next()

@@ -86,17 +75,6 @@

return headerParts[1], true } -func saveUserIDToCtx(c *gin.Context, us usersrv.UserServicer, token string) error { - pl, err := us.ParseToken(token) - if err != nil { - return err - } - - c.Set(userIDCtxKey, pl.UserID) - - return nil -} - // getUserId returns userId from the context // getting user id is only possible if user is authorized func (a *APIV1) getUserID(c *gin.Context) uuid.UUID {

@@ -104,21 +82,34 @@ userID, exists := c.Get(userIDCtxKey)

if !exists { return uuid.Nil } - return uuid.Must(uuid.FromString(userID.(string))) + return userID.(uuid.UUID) } -func checkIfUserIsReal( - ctx context.Context, - accessToken string, - us usersrv.UserServicer, -) (bool, error) { - parsedToken, err := us.ParseToken(accessToken) +func (a *APIV1) validateAuthorizedUser(ctx context.Context, accessToken string) (uuid.UUID, error) { + tokenPayload, err := a.usersrv.ParseJWTToken(accessToken) if err != nil { - return false, err + return uuid.Nil, err } - return us.CheckIfUserExists( - ctx, - uuid.Must(uuid.FromString(parsedToken.UserID)), - ) + userID := uuid.Must(uuid.FromString(tokenPayload.UserID)) + + ok, err := a.usersrv.CheckIfUserExists(ctx, userID) + if err != nil { + return uuid.Nil, err + } + + if !ok { + return uuid.Nil, ErrUnauthorized + } + + ok, err = a.usersrv.CheckIfUserIsActivated(ctx, userID) + if err != nil { + return uuid.Nil, err + } + + if !ok { + return uuid.Nil, models.ErrUserIsNotActivated + } + + return userID, nil }
M internal/transport/http/apiv1/note.go

@@ -1,7 +1,6 @@

package apiv1 import ( - "log/slog" "net/http" "time"

@@ -40,8 +39,6 @@ if err := note.Validate(); err != nil {

newErrorStatus(c, http.StatusBadRequest, err.Error()) return } - - slog.Debug("userid", "a", a.getUserID(c)) slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNoteDTO{ Content: note.Content,
M internal/transport/http/apiv1/response.go

@@ -9,6 +9,8 @@ "github.com/gin-gonic/gin"

"github.com/olexsmir/onasty/internal/models" ) +var ErrUnauthorized = errors.New("unauthorized") + type response struct { Message string `json:"message"` }

@@ -17,7 +19,9 @@ func errorResponse(c *gin.Context, err error) {

if errors.Is(err, models.ErrUserEmailIsAlreadyInUse) || errors.Is(err, models.ErrUsernameIsAlreadyInUse) || errors.Is(err, models.ErrNoteContentIsEmpty) || - errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) { + errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) || + errors.Is(err, models.ErrUserIsNotActivated) || + errors.Is(err, models.ErrUserIsAlreeadyVerified) { newError(c, http.StatusBadRequest, err.Error()) return }

@@ -27,7 +31,8 @@ newError(c, http.StatusGone, err.Error())

return } - if errors.Is(err, models.ErrNoteNotFound) { + if errors.Is(err, models.ErrNoteNotFound) || + errors.Is(err, models.ErrVerificationTokenNotFound) { newErrorStatus(c, http.StatusNotFound, err.Error()) return }
A migrations/20240729115827_verification_tokens.down.sql

@@ -0,0 +1,1 @@

+drop table verification_tokens;
A migrations/20240729115827_verification_tokens.up.sql

@@ -0,0 +1,8 @@

+create table verification_tokens ( + id uuid primary key default uuid_generate_v4(), + user_id uuid not null unique references users(id), + token varchar(255) not null unique, + created_at timestamptz not null default now(), + expires_at timestamptz not null, + used_at timestamptz default null +);