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
        
        -APP_ENV="debug"

      
        
        1
        +APP_ENV=debug

      
        2
        2
         SERVER_PORT=3000

      
        3
        
        -PASSWORD_SALT="onasty"

      
        
        3
        +PASSWORD_SALT=onasty

      
        4
        4
         

      
        5
        
        -LOG_LEVEL="debug"

      
        6
        
        -LOG_FORMAT="text"

      
        
        5
        +LOG_LEVEL=debug

      
        
        6
        +LOG_FORMAT=text

      
        
        7
        +LOG_SHOW_LINE=true

      
        7
        8
         

      
        8
        
        -JWT_SIGNING_KEY="supersecret"

      
        9
        
        -JWT_ACCESS_TOKEN_TTL="30m"

      
        10
        
        -JWT_REFRESH_TOKEN_TTL="15d"

      
        
        9
        +JWT_SIGNING_KEY=supersecret

      
        
        10
        +JWT_ACCESS_TOKEN_TTL=30m

      
        
        11
        +JWT_REFRESH_TOKEN_TTL=360d

      
        11
        12
         

      
        12
        
        -POSTGRES_USERNAME="onasty"

      
        13
        
        -POSTGRES_PASSWORD="qwerty"

      
        14
        
        -POSTGRES_HOST="127.0.0.1"

      
        
        13
        +POSTGRES_USERNAME=onasty

      
        
        14
        +POSTGRES_PASSWORD=qwerty

      
        
        15
        +POSTGRES_HOST=127.0.0.1

      
        15
        16
         POSTGRES_PORT=5432

      
        16
        
        -POSTGRES_DATABASE="onasty"

      
        
        17
        +POSTGRES_DATABASE=onasty

      
        17
        18
         POSTGRESQL_DSN="postgres://$POSTGRES_USERNAME:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DATABASE?sslmode=disable"

      
        
        19
        +

      
        
        20
        +MAILGUN_FROM=onasty@mail.com

      
        
        21
        +MAILGUN_DOMAI='<domain>'

      
        
        22
        +MAILGUN_API_KEY='<token>'

      
        
        23
        +VERIFICATION_TOKEN_TTL=48h

      
M cmd/server/main.go
···
        13
        13
         	"github.com/olexsmir/onasty/internal/config"

      
        14
        14
         	"github.com/olexsmir/onasty/internal/hasher"

      
        15
        15
         	"github.com/olexsmir/onasty/internal/jwtutil"

      
        
        16
        +	"github.com/olexsmir/onasty/internal/mailer"

      
        16
        17
         	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        17
        18
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        18
        19
         	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

      
        19
        20
         	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"

      
        20
        21
         	"github.com/olexsmir/onasty/internal/store/psql/userepo"

      
        
        22
        +	"github.com/olexsmir/onasty/internal/store/psql/vertokrepo"

      
        21
        23
         	"github.com/olexsmir/onasty/internal/store/psqlutil"

      
        22
        24
         	httptransport "github.com/olexsmir/onasty/internal/transport/http"

      
        23
        25
         	"github.com/olexsmir/onasty/internal/transport/http/httpserver"

      ···
        51
        53
         	// app deps

      
        52
        54
         	sha256Hasher := hasher.NewSHA256Hasher(cfg.PasswordSalt)

      
        53
        55
         	jwtTokenizer := jwtutil.NewJWTUtil(cfg.JwtSigningKey, cfg.JwtAccessTokenTTL)

      
        
        56
        +	mailGunMailer := mailer.NewMailgun(cfg.MailgunFrom, cfg.MailgunDomain, cfg.MailgunAPIKey)

      
        54
        57
         

      
        55
        58
         	sessionrepo := sessionrepo.New(psqlDB)

      
        
        59
        +	vertokrepo := vertokrepo.New(psqlDB)

      
        56
        60
         

      
        57
        61
         	userepo := userepo.New(psqlDB)

      
        58
        
        -	usersrv := usersrv.New(userepo, sessionrepo, sha256Hasher, jwtTokenizer)

      
        
        62
        +	usersrv := usersrv.New(

      
        
        63
        +		userepo,

      
        
        64
        +		sessionrepo,

      
        
        65
        +		vertokrepo,

      
        
        66
        +		sha256Hasher,

      
        
        67
        +		jwtTokenizer,

      
        
        68
        +		mailGunMailer,

      
        
        69
        +		cfg.JwtRefreshTokenTTL,

      
        
        70
        +		cfg.VerficationTokenTTL,

      
        
        71
        +	)

      
        59
        72
         

      
        60
        73
         	noterepo := noterepo.New(psqlDB)

      
        61
        74
         	notesrv := notesrv.New(noterepo)

      ···
        65
        78
         	// http server

      
        66
        79
         	srv := httpserver.NewServer(cfg.ServerPort, handler.Handler())

      
        67
        80
         	go func() {

      
        68
        
        -		slog.Info("starting http server", "port", cfg.ServerPort)

      
        
        81
        +		slog.Debug("starting http server", "port", cfg.ServerPort)

      
        69
        82
         		if err := srv.Start(); !errors.Is(err, http.ErrServerClosed) {

      
        70
        83
         			slog.Error("failed to start http server", "error", err)

      
        71
        84
         		}

      ···
        100
        113
         		return errors.New("unknown log level")

      
        101
        114
         	}

      
        102
        115
         

      
        
        116
        +	handlerOptions := &slog.HandlerOptions{

      
        
        117
        +		Level:     logLevel,

      
        
        118
        +		AddSource: cfg.LogShowLine,

      
        
        119
        +	}

      
        
        120
        +

      
        103
        121
         	var slogHandler slog.Handler

      
        104
        122
         	switch cfg.LogFormat {

      
        105
        123
         	case "json":

      
        106
        
        -		slogHandler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})

      
        
        124
        +		slogHandler = slog.NewJSONHandler(os.Stdout, handlerOptions)

      
        107
        125
         	case "text":

      
        108
        
        -		slogHandler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})

      
        
        126
        +		slogHandler = slog.NewTextHandler(os.Stdout, handlerOptions)

      
        109
        127
         	default:

      
        110
        128
         		return errors.New("unknown log format")

      
        111
        129
         	}

      
M docker-compose.yml
···
        1
        1
         services:

      
        2
        2
           postgres:

      
        3
        
        -    image: postgres:16

      
        
        3
        +    image: postgres:16-alpine

      
        4
        4
             container_name: onasty-postgres

      
        5
        5
             environment:

      
        6
        6
               POSTGRES_USER: onasty

      
M e2e/apiv1_auth_test.go
···
        4
        4
         	"net/http"

      
        5
        5
         

      
        6
        6
         	"github.com/gofrs/uuid/v5"

      
        
        7
        +	"github.com/olexsmir/onasty/internal/models"

      
        7
        8
         )

      
        8
        9
         

      
        9
        10
         type apiv1AuthSignUpRequest struct {

      ···
        81
        82
         		RefreshToken string `json:"refresh_token"`

      
        82
        83
         	}

      
        83
        84
         )

      
        
        85
        +

      
        
        86
        +func (e *AppTestSuite) TestAuthV1_VerifyEmail() {

      
        
        87
        +	email := e.uuid() + "email@email.com"

      
        
        88
        +	password := "qwerty"

      
        
        89
        +

      
        
        90
        +	httpResp := e.httpRequest(

      
        
        91
        +		http.MethodPost,

      
        
        92
        +		"/api/v1/auth/signup",

      
        
        93
        +		e.jsonify(apiv1AuthSignUpRequest{

      
        
        94
        +			Username: e.uuid(),

      
        
        95
        +			Email:    email,

      
        
        96
        +			Password: password,

      
        
        97
        +		}),

      
        
        98
        +	)

      
        
        99
        +

      
        
        100
        +	e.Equal(http.StatusCreated, httpResp.Code)

      
        
        101
        +

      
        
        102
        +	// TODO: probably should get the token from the email

      
        
        103
        +

      
        
        104
        +	user := e.getLastInsertedUserByEmail(email)

      
        
        105
        +	token := e.getVerificationTokenByUserID(user.ID)

      
        
        106
        +	httpResp = e.httpRequest(http.MethodGet, "/api/v1/auth/verify/"+token.Token, nil)

      
        
        107
        +	e.Equal(http.StatusOK, httpResp.Code)

      
        
        108
        +

      
        
        109
        +	user = e.getLastInsertedUserByEmail(email)

      
        
        110
        +	e.Equal(user.Activated, true)

      
        
        111
        +}

      
        
        112
        +

      
        
        113
        +func (e *AppTestSuite) TestAuthV1_ResendVerificationEmail() {

      
        
        114
        +	email, password := e.uuid()+"email@email.com", e.uuid()

      
        
        115
        +

      
        
        116
        +	// create test user

      
        
        117
        +	signUpHTTPResp := e.httpRequest(

      
        
        118
        +		http.MethodPost,

      
        
        119
        +		"/api/v1/auth/signup",

      
        
        120
        +		e.jsonify(apiv1AuthSignUpRequest{

      
        
        121
        +			Username: e.uuid(),

      
        
        122
        +			Email:    email,

      
        
        123
        +			Password: password,

      
        
        124
        +		}),

      
        
        125
        +	)

      
        
        126
        +

      
        
        127
        +	e.Equal(http.StatusCreated, signUpHTTPResp.Code)

      
        
        128
        +

      
        
        129
        +	// handle sending of the email

      
        
        130
        +	httpResp := e.httpRequest(

      
        
        131
        +		http.MethodPost,

      
        
        132
        +		"/api/v1/auth/resend-verification-email",

      
        
        133
        +		e.jsonify(apiv1AuthSignInRequest{

      
        
        134
        +			Email:    email,

      
        
        135
        +			Password: password,

      
        
        136
        +		}),

      
        
        137
        +	)

      
        
        138
        +

      
        
        139
        +	e.Equal(http.StatusOK, httpResp.Code)

      
        
        140
        +	e.NotEmpty(e.mailer.GetLastSentEmailToEmail(email))

      
        
        141
        +}

      
        
        142
        +

      
        
        143
        +func (e *AppTestSuite) TestAuthV1_ResendVerificationEmail_wrong() {

      
        
        144
        +	email, password := e.uuid()+"@"+e.uuid()+".com", "password"

      
        
        145
        +	e.insertUserIntoDB(e.uuid(), email, password, true)

      
        
        146
        +

      
        
        147
        +	tests := []struct {

      
        
        148
        +		name         string

      
        
        149
        +		email        string

      
        
        150
        +		password     string

      
        
        151
        +		expectedCode int

      
        
        152
        +	}{

      
        
        153
        +		{

      
        
        154
        +			name:         "activated account",

      
        
        155
        +			email:        email,

      
        
        156
        +			password:     password,

      
        
        157
        +			expectedCode: http.StatusBadRequest,

      
        
        158
        +		},

      
        
        159
        +		{

      
        
        160
        +			name:         "wrong credintials",

      
        
        161
        +			email:        email,

      
        
        162
        +			password:     e.uuid(),

      
        
        163
        +			expectedCode: http.StatusUnauthorized,

      
        
        164
        +		},

      
        
        165
        +	}

      
        
        166
        +

      
        
        167
        +	for _, t := range tests {

      
        
        168
        +		httpResp := e.httpRequest(

      
        
        169
        +			http.MethodPost,

      
        
        170
        +			"/api/v1/auth/resend-verification-email",

      
        
        171
        +			e.jsonify(apiv1AuthSignInRequest{

      
        
        172
        +				Email:    t.email,

      
        
        173
        +				Password: t.password,

      
        
        174
        +			}))

      
        
        175
        +

      
        
        176
        +		e.Equal(httpResp.Code, t.expectedCode)

      
        
        177
        +

      
        
        178
        +		// no email should be sent

      
        
        179
        +		e.Empty(e.mailer.GetLastSentEmailToEmail(t.email))

      
        
        180
        +	}

      
        
        181
        +}

      
        84
        182
         

      
        85
        183
         func (e *AppTestSuite) TestAuthV1_SignIn() {

      
        86
        184
         	email := e.uuid() + "email@email.com"

      
        87
        185
         	password := "qwerty"

      
        88
        186
         

      
        89
        
        -	uid := e.insertUserIntoDB("test", email, password)

      
        
        187
        +	uid := e.insertUserIntoDB("test", email, password, true)

      
        90
        188
         

      
        91
        189
         	httpResp := e.httpRequest(

      
        92
        190
         		http.MethodPost,

      ···
        111
        209
         func (e *AppTestSuite) TestAuthV1_SignIn_wrong() {

      
        112
        210
         	password := "password"

      
        113
        211
         	email := e.uuid() + "@test.com"

      
        114
        
        -	e.insertUserIntoDB(e.uuid(), email, "password")

      
        
        212
        +	e.insertUserIntoDB(e.uuid(), email, "password", true)

      
        
        213
        +

      
        
        214
        +	unactivatedEmail := e.uuid() + "@test.com"

      
        
        215
        +	e.insertUserIntoDB(e.uuid(), unactivatedEmail, password, false)

      
        115
        216
         

      
        116
        217
         	tests := []struct {

      
        117
        
        -		name     string

      
        118
        
        -		email    string

      
        119
        
        -		password string

      
        
        218
        +		name         string

      
        
        219
        +		email        string

      
        
        220
        +		password     string

      
        
        221
        +		expectedCode int

      
        
        222
        +

      
        
        223
        +		expectMsg   bool

      
        
        224
        +		expectedMsg string

      
        120
        225
         	}{

      
        121
        226
         		{

      
        122
        
        -			name:     "wrong email",

      
        123
        
        -			email:    "wrong@emai.com",

      
        124
        
        -			password: password,

      
        
        227
        +			name:         "unactivated user",

      
        
        228
        +			email:        unactivatedEmail,

      
        
        229
        +			password:     password,

      
        
        230
        +			expectedCode: http.StatusBadRequest,

      
        
        231
        +			expectMsg:    true,

      
        
        232
        +			expectedMsg:  models.ErrUserIsNotActivated.Error(),

      
        
        233
        +		},

      
        
        234
        +		{

      
        
        235
        +			name:         "wrong email",

      
        
        236
        +			email:        "wrong@emai.com",

      
        
        237
        +			password:     password,

      
        
        238
        +			expectedCode: http.StatusUnauthorized,

      
        125
        239
         		},

      
        126
        240
         		{

      
        127
        
        -			name:     "wrong password",

      
        128
        
        -			email:    email,

      
        129
        
        -			password: "wrong-wrong",

      
        
        241
        +			name:         "wrong password",

      
        
        242
        +			email:        email,

      
        
        243
        +			password:     "wrong-wrong",

      
        
        244
        +			expectedCode: http.StatusUnauthorized,

      
        130
        245
         		},

      
        131
        246
         	}

      
        132
        247
         

      ···
        140
        255
         			}),

      
        141
        256
         		)

      
        142
        257
         

      
        143
        
        -		e.Equal(http.StatusUnauthorized, httpResp.Code)

      
        
        258
        +		if t.expectMsg {

      
        
        259
        +			var body errorResponse

      
        
        260
        +			e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        
        261
        +

      
        
        262
        +			e.Equal(body.Message, t.expectedMsg)

      
        
        263
        +		}

      
        
        264
        +

      
        
        265
        +		e.Equal(t.expectedCode, httpResp.Code)

      
        144
        266
         	}

      
        145
        267
         }

      
        146
        268
         

      ···
        161
        283
         	var body apiv1AuthSignInResponse

      
        162
        284
         	e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        163
        285
         

      
        164
        
        -	session := e.getLastUserSessionByUserID(uid)

      
        165
        
        -	parsedToken := e.parseJwtToken(body.AccessToken)

      
        166
        
        -	e.Equal(parsedToken.UserID, uid.String())

      
        
        286
        +	sessionDB := e.getLastUserSessionByUserID(uid)

      
        
        287
        +	e.Equal(e.parseJwtToken(body.AccessToken).UserID, uid.String())

      
        167
        288
         

      
        168
        289
         	e.Equal(httpResp.Code, http.StatusOK)

      
        169
        290
         	e.NotEqual(toks.RefreshToken, body.RefreshToken)

      
        170
        
        -	e.Equal(body.RefreshToken, session.RefreshToken)

      
        
        291
        +	e.Equal(body.RefreshToken, sessionDB.RefreshToken)

      
        171
        292
         }

      
        172
        293
         

      
        173
        294
         func (e *AppTestSuite) TestAuthV1_RefreshTokens_wrong() {

      
        
        295
        +	// requests a new token pair with a wrong refresh token

      
        
        296
        +

      
        174
        297
         	httpResp := e.httpRequest(

      
        175
        298
         		http.MethodPost,

      
        176
        299
         		"/api/v1/auth/refresh-tokens",

      ···
        185
        308
         func (e *AppTestSuite) TestAuthV1_Logout() {

      
        186
        309
         	uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password")

      
        187
        310
         

      
        188
        
        -	session := e.getLastUserSessionByUserID(uid)

      
        189
        
        -	e.NotEmpty(session.RefreshToken)

      
        
        311
        +	sessionDB := e.getLastUserSessionByUserID(uid)

      
        
        312
        +	e.NotEmpty(sessionDB.RefreshToken)

      
        190
        313
         

      
        191
        314
         	httpResp := e.httpRequest(http.MethodPost, "/api/v1/auth/logout", nil, toks.AccessToken)

      
        
        315
        +	e.Equal(httpResp.Code, http.StatusNoContent)

      
        192
        316
         

      
        193
        
        -	e.Equal(httpResp.Code, http.StatusNoContent)

      
        
        317
        +	sessionDB = e.getLastUserSessionByUserID(uid)

      
        
        318
        +	e.Empty(sessionDB.RefreshToken)

      
        
        319
        +}

      
        
        320
        +

      
        
        321
        +type apiv1AtuhChangePasswordRequest struct {

      
        
        322
        +	CurrentPassword string `json:"current_password"`

      
        
        323
        +	NewPassword     string `json:"new_password"`

      
        
        324
        +}

      
        
        325
        +

      
        
        326
        +func (e *AppTestSuite) TestAuthV1_ChangePassword() {

      
        
        327
        +	password := e.uuid()

      
        
        328
        +	newPassword := e.uuid()

      
        
        329
        +	username := e.uuid()

      
        
        330
        +	_, toks := e.createAndSingIn(e.uuid()+"@test.com", username, password)

      
        194
        331
         

      
        195
        
        -	session = e.getLastUserSessionByUserID(uid)

      
        196
        
        -	e.Empty(session.RefreshToken)

      
        
        332
        +	httpResp := e.httpRequest(

      
        
        333
        +		http.MethodPost,

      
        
        334
        +		"/api/v1/auth/change-password",

      
        
        335
        +		e.jsonify(apiv1AtuhChangePasswordRequest{

      
        
        336
        +			CurrentPassword: password,

      
        
        337
        +			NewPassword:     newPassword,

      
        
        338
        +		}),

      
        
        339
        +		toks.AccessToken,

      
        
        340
        +	)

      
        
        341
        +

      
        
        342
        +	e.Equal(httpResp.Code, http.StatusOK)

      
        
        343
        +

      
        
        344
        +	userDB := e.getUserFromDBByUsername(username)

      
        
        345
        +	hashedNewPassword, err := e.hasher.Hash(newPassword)

      
        
        346
        +	e.require.NoError(err)

      
        
        347
        +

      
        
        348
        +	e.Equal(userDB.Password, hashedNewPassword)

      
        197
        349
         }

      
        198
        350
         

      
        199
        351
         func (e *AppTestSuite) createAndSingIn(

      
        200
        352
         	email, username, password string,

      
        201
        353
         ) (uuid.UUID, apiv1AuthSignInResponse) {

      
        202
        
        -	uid := e.insertUserIntoDB(username, email, password)

      
        
        354
        +	uid := e.insertUserIntoDB(username, email, password, true)

      
        203
        355
         	httpResp := e.httpRequest(

      
        204
        356
         		http.MethodPost,

      
        205
        357
         		"/api/v1/auth/signin",

      
M e2e/e2e_test.go
···
        3
        3
         import (

      
        4
        4
         	"context"

      
        5
        5
         	"fmt"

      
        
        6
        +	"log/slog"

      
        6
        7
         	"net/http"

      
        
        8
        +	"os"

      
        7
        9
         	"testing"

      
        8
        10
         	"time"

      
        9
        11
         

      ···
        11
        13
         	"github.com/golang-migrate/migrate/v4"

      
        12
        14
         	"github.com/golang-migrate/migrate/v4/database/pgx"

      
        13
        15
         	"github.com/jackc/pgx/v5/stdlib"

      
        
        16
        +	"github.com/olexsmir/onasty/internal/config"

      
        14
        17
         	"github.com/olexsmir/onasty/internal/hasher"

      
        15
        18
         	"github.com/olexsmir/onasty/internal/jwtutil"

      
        
        19
        +	"github.com/olexsmir/onasty/internal/mailer"

      
        16
        20
         	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        17
        21
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        18
        22
         	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

      
        19
        23
         	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"

      
        20
        24
         	"github.com/olexsmir/onasty/internal/store/psql/userepo"

      
        
        25
        +	"github.com/olexsmir/onasty/internal/store/psql/vertokrepo"

      
        21
        26
         	"github.com/olexsmir/onasty/internal/store/psqlutil"

      
        22
        27
         	httptransport "github.com/olexsmir/onasty/internal/transport/http"

      
        23
        28
         	"github.com/stretchr/testify/require"

      ···
        43
        48
         		router       http.Handler

      
        44
        49
         		hasher       hasher.Hasher

      
        45
        50
         		jwtTokenizer jwtutil.JWTTokenizer

      
        
        51
        +		mailer       *mailer.TestMailer

      
        
        52
        +	}

      
        
        53
        +	errorResponse struct {

      
        
        54
        +		Message string `json:"message"`

      
        46
        55
         	}

      
        47
        56
         )

      
        48
        57
         

      ···
        62
        71
         	e.require = e.Require()

      
        63
        72
         

      
        64
        73
         	db, stop, err := e.prepPostgres()

      
        65
        
        -	e.Require().NoError(err)

      
        
        74
        +	e.require.NoError(err)

      
        66
        75
         

      
        67
        76
         	e.postgresDB = db

      
        68
        77
         	e.stopPostgres = stop

      
        69
        78
         

      
        
        79
        +	e.setupLogger()

      
        70
        80
         	e.initDeps()

      
        71
        81
         }

      
        72
        82
         

      ···
        77
        87
         // initDeps initializes the dependencies for the app

      
        78
        88
         // and sets up the router for tests

      
        79
        89
         func (e *AppTestSuite) initDeps() {

      
        80
        
        -	e.hasher = hasher.NewSHA256Hasher("pass_salt")

      
        81
        
        -	e.jwtTokenizer = jwtutil.NewJWTUtil("jwt", time.Hour)

      
        
        90
        +	cfg := e.getConfig()

      
        
        91
        +

      
        
        92
        +	e.hasher = hasher.NewSHA256Hasher(cfg.PasswordSalt)

      
        
        93
        +	e.jwtTokenizer = jwtutil.NewJWTUtil(cfg.JwtSigningKey, time.Hour)

      
        
        94
        +	e.mailer = mailer.NewTestMailer()

      
        82
        95
         

      
        83
        96
         	sessionrepo := sessionrepo.New(e.postgresDB)

      
        
        97
        +	vertokrepo := vertokrepo.New(e.postgresDB)

      
        84
        98
         

      
        85
        99
         	userepo := userepo.New(e.postgresDB)

      
        86
        
        -	usersrv := usersrv.New(userepo, sessionrepo, e.hasher, e.jwtTokenizer)

      
        
        100
        +	usersrv := usersrv.New(

      
        
        101
        +		userepo,

      
        
        102
        +		sessionrepo,

      
        
        103
        +		vertokrepo,

      
        
        104
        +		e.hasher,

      
        
        105
        +		e.jwtTokenizer,

      
        
        106
        +		e.mailer,

      
        
        107
        +		cfg.JwtRefreshTokenTTL,

      
        
        108
        +		cfg.VerficationTokenTTL,

      
        
        109
        +	)

      
        87
        110
         

      
        88
        111
         	noterepo := noterepo.New(e.postgresDB)

      
        89
        112
         	notesrv := notesrv.New(noterepo)

      ···
        143
        166
         

      
        144
        167
         	return db, stop, driver.Close()

      
        145
        168
         }

      
        
        169
        +

      
        
        170
        +func (e *AppTestSuite) setupLogger() {

      
        
        171
        +	slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{

      
        
        172
        +		Level:     slog.LevelDebug,

      
        
        173
        +		AddSource: os.Getenv("LOG_SHOW_LINE") == "true",

      
        
        174
        +	})))

      
        
        175
        +}

      
        
        176
        +

      
        
        177
        +func (e *AppTestSuite) getConfig() *config.Config {

      
        
        178
        +	return &config.Config{

      
        
        179
        +		AppEnv:              "testing",

      
        
        180
        +		ServerPort:          "3000",

      
        
        181
        +		PasswordSalt:        "salty-password",

      
        
        182
        +		JwtSigningKey:       "jwt-key",

      
        
        183
        +		JwtAccessTokenTTL:   time.Hour,

      
        
        184
        +		JwtRefreshTokenTTL:  24 * time.Hour,

      
        
        185
        +		VerficationTokenTTL: 24 * time.Hour,

      
        
        186
        +	}

      
        
        187
        +}

      
M e2e/e2e_utils_db_test.go
···
        14
        14
         	query, args, err := pgq.

      
        15
        15
         		Select("id", "username", "email", "password", "created_at", "last_login_at").

      
        16
        16
         		From("users").

      
        17
        
        -		Where(pgq.Eq{

      
        18
        
        -			"username": username,

      
        19
        
        -		}).

      
        
        17
        +		Where(pgq.Eq{"username": username}).

      
        20
        18
         		SQL()

      
        21
        19
         	e.require.NoError(err)

      
        22
        20
         

      ···
        28
        26
         	return user

      
        29
        27
         }

      
        30
        28
         

      
        31
        
        -func (e *AppTestSuite) insertUserIntoDB(uname, email, passwd string) uuid.UUID {

      
        
        29
        +func (e *AppTestSuite) insertUserIntoDB(uname, email, passwd string, activated ...bool) uuid.UUID {

      
        32
        30
         	p, err := e.hasher.Hash(passwd)

      
        33
        31
         	e.require.NoError(err)

      
        34
        32
         

      
        
        33
        +	var a bool

      
        
        34
        +	if len(activated) == 1 {

      
        
        35
        +		a = activated[0]

      
        
        36
        +	}

      
        
        37
        +

      
        35
        38
         	query, args, err := pgq.

      
        36
        39
         		Insert("users").

      
        37
        40
         		Columns("username", "email", "password", "activated", "created_at", "last_login_at").

      
        38
        
        -		Values(uname, email, p, true, time.Now(), time.Now()).

      
        
        41
        +		Values(uname, email, p, a, time.Now(), time.Now()).

      
        39
        42
         		Returning("id").

      
        40
        43
         		SQL()

      
        41
        44
         	e.require.NoError(err)

      ···
        59
        62
         	var session models.Session

      
        60
        63
         	err = e.postgresDB.QueryRow(e.ctx, query, args...).

      
        61
        64
         		Scan(&session.RefreshToken, &session.ExpiresAt)

      
        62
        
        -	if errors.Is(pgx.ErrNoRows, err) {

      
        
        65
        +	if errors.Is(err, pgx.ErrNoRows) {

      
        63
        66
         		return models.Session{}

      
        64
        67
         	}

      
        65
        68
         

      ···
        67
        70
         	return session

      
        68
        71
         }

      
        69
        72
         

      
        
        73
        +func (e *AppTestSuite) getLastInsertedUserByEmail(em string) models.User {

      
        
        74
        +	query, args, err := pgq.

      
        
        75
        +		Select("id", "username", "activated", "email", "password").

      
        
        76
        +		From("users").

      
        
        77
        +		Where(pgq.Eq{"email": em}).

      
        
        78
        +		OrderBy("created_at DESC").

      
        
        79
        +		Limit(1).

      
        
        80
        +		SQL()

      
        
        81
        +	e.require.NoError(err)

      
        
        82
        +

      
        
        83
        +	var u models.User

      
        
        84
        +	err = e.postgresDB.QueryRow(e.ctx, query, args...).

      
        
        85
        +		Scan(&u.ID, &u.Username, &u.Activated, &u.Email, &u.Password)

      
        
        86
        +	if errors.Is(err, pgx.ErrNoRows) {

      
        
        87
        +		return models.User{}

      
        
        88
        +	}

      
        
        89
        +

      
        
        90
        +	e.require.NoError(err)

      
        
        91
        +	return u

      
        
        92
        +}

      
        
        93
        +

      
        70
        94
         func (e *AppTestSuite) getNoteFromDBbySlug(slug string) models.Note {

      
        71
        95
         	query, args, err := pgq.

      
        72
        96
         		Select("id", "content", "slug", "burn_before_expiration", "created_at", "expires_at").

      
        73
        97
         		From("notes").

      
        74
        
        -		Where("slug = ?", slug).

      
        
        98
        +		Where(pgq.Eq{"slug": slug}).

      
        75
        99
         		SQL()

      
        76
        100
         	e.require.NoError(err)

      
        77
        101
         

      ···
        110
        134
         	e.require.NoError(err)

      
        111
        135
         	return na

      
        112
        136
         }

      
        
        137
        +

      
        
        138
        +type userVerificationToken struct {

      
        
        139
        +	Token  string

      
        
        140
        +	UsedAt *time.Time

      
        
        141
        +}

      
        
        142
        +

      
        
        143
        +func (e *AppTestSuite) getVerificationTokenByUserID(u uuid.UUID) userVerificationToken {

      
        
        144
        +	query, args, err := pgq.

      
        
        145
        +		Select("token", "used_at").

      
        
        146
        +		From("verification_tokens").

      
        
        147
        +		Where(pgq.Eq{"user_id": u.String()}).

      
        
        148
        +		SQL()

      
        
        149
        +	e.require.NoError(err)

      
        
        150
        +	var r userVerificationToken

      
        
        151
        +	err = e.postgresDB.QueryRow(e.ctx, query, args...).Scan(&r.Token, &r.UsedAt)

      
        
        152
        +	e.require.NoError(err)

      
        
        153
        +	return r

      
        
        154
        +}

      
M go.mod
···
        8
        8
         	github.com/golang-jwt/jwt/v5 v5.2.1

      
        9
        9
         	github.com/golang-migrate/migrate/v4 v4.17.1

      
        10
        10
         	github.com/henvic/pgq v0.0.2

      
        11
        
        -	github.com/jackc/pgconn v1.14.3

      
        12
        11
         	github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2

      
        13
        12
         	github.com/jackc/pgx/v5 v5.6.0

      
        
        13
        +	github.com/mailgun/mailgun-go/v4 v4.12.0

      
        14
        14
         	github.com/stretchr/testify v1.9.0

      
        15
        15
         	github.com/testcontainers/testcontainers-go v0.33.0

      
        16
        16
         	github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0

      ···
        37
        37
         	github.com/felixge/httpsnoop v1.0.4 // indirect

      
        38
        38
         	github.com/gabriel-vasile/mimetype v1.4.3 // indirect

      
        39
        39
         	github.com/gin-contrib/sse v0.1.0 // indirect

      
        
        40
        +	github.com/go-chi/chi/v5 v5.0.8 // indirect

      
        40
        41
         	github.com/go-logr/logr v1.4.1 // indirect

      
        41
        42
         	github.com/go-logr/stdr v1.2.2 // indirect

      
        42
        43
         	github.com/go-ole/go-ole v1.2.6 // indirect

      ···
        49
        50
         	github.com/hashicorp/errwrap v1.1.0 // indirect

      
        50
        51
         	github.com/hashicorp/go-multierror v1.1.1 // indirect

      
        51
        52
         	github.com/jackc/chunkreader/v2 v2.0.1 // indirect

      
        
        53
        +	github.com/jackc/pgconn v1.14.3 // indirect

      
        52
        54
         	github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect

      
        53
        55
         	github.com/jackc/pgio v1.0.0 // indirect

      
        54
        56
         	github.com/jackc/pgpassfile v1.0.0 // indirect

      
M go.sum
···
        47
        47
         github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=

      
        48
        48
         github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=

      
        49
        49
         github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=

      
        
        50
        +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ=

      
        
        51
        +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=

      
        
        52
        +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=

      
        
        53
        +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=

      
        
        54
        +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y=

      
        
        55
        +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=

      
        50
        56
         github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=

      
        51
        57
         github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=

      
        52
        58
         github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=

      ···
        55
        61
         github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=

      
        56
        62
         github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=

      
        57
        63
         github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=

      
        
        64
        +github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=

      
        
        65
        +github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=

      
        58
        66
         github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=

      
        59
        67
         github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=

      
        60
        68
         github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=

      ···
        157
        165
         github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=

      
        158
        166
         github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=

      
        159
        167
         github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=

      
        
        168
        +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=

      
        160
        169
         github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=

      
        161
        170
         github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=

      
        162
        171
         github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=

      ···
        189
        198
         github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=

      
        190
        199
         github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=

      
        191
        200
         github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=

      
        
        201
        +github.com/mailgun/mailgun-go/v4 v4.12.0 h1:TtuQCgqSp4cB6swPxP5VF/u4JeeBIAjTdpuQ+4Usd/w=

      
        
        202
        +github.com/mailgun/mailgun-go/v4 v4.12.0/go.mod h1:L9s941Lgk7iB3TgywTPz074pK2Ekkg4kgbnAaAyJ2z8=

      
        192
        203
         github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=

      
        193
        204
         github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=

      
        194
        205
         github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=

      ···
        209
        220
         github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

      
        210
        221
         github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=

      
        211
        222
         github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

      
        
        223
        +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=

      
        212
        224
         github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=

      
        213
        225
         github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=

      
        214
        226
         github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=

      
M internal/config/config.go
···
        1
        1
         package config

      
        2
        2
         

      
        3
        3
         import (

      
        
        4
        +	"errors"

      
        4
        5
         	"os"

      
        5
        6
         	"time"

      
        6
        7
         )

      ···
        8
        9
         type Config struct {

      
        9
        10
         	AppEnv       string

      
        10
        11
         	ServerPort   string

      
        
        12
        +	PostgresDSN  string

      
        11
        13
         	PasswordSalt string

      
        12
        14
         

      
        13
        15
         	JwtSigningKey      string

      
        14
        16
         	JwtAccessTokenTTL  time.Duration

      
        15
        17
         	JwtRefreshTokenTTL time.Duration

      
        16
        18
         

      
        17
        
        -	LogLevel  string

      
        18
        
        -	LogFormat string

      
        
        19
        +	MailgunFrom         string

      
        
        20
        +	MailgunDomain       string

      
        
        21
        +	MailgunAPIKey       string

      
        
        22
        +	VerficationTokenTTL time.Duration

      
        19
        23
         

      
        20
        
        -	PostgresDSN string

      
        
        24
        +	LogLevel    string

      
        
        25
        +	LogFormat   string

      
        
        26
        +	LogShowLine bool

      
        21
        27
         }

      
        22
        28
         

      
        23
        29
         func NewConfig() *Config {

      
        24
        30
         	return &Config{

      
        25
        
        -		AppEnv:             getenvOrDefault("APP_ENV", "debug"),

      
        26
        
        -		ServerPort:         getenvOrDefault("SERVER_PORT", "3000"),

      
        27
        
        -		PasswordSalt:       getenvOrDefault("PASSWORD_SALT", ""),

      
        28
        
        -		JwtSigningKey:      getenvOrDefault("JWT_SIGNING_KEY", ""),

      
        29
        
        -		JwtAccessTokenTTL:  mustParseDuration(getenvOrDefault("JWT_ACCESS_TOKEN_TTL", "15m")),

      
        30
        
        -		JwtRefreshTokenTTL: mustParseDuration(getenvOrDefault("JWT_REFRESH_TOKEN_TTL", "15d")),

      
        31
        
        -		LogLevel:           getenvOrDefault("LOG_LEVEL", "debug"),

      
        32
        
        -		LogFormat:          getenvOrDefault("LOG_FORMAT", "json"),

      
        33
        
        -		PostgresDSN:        getenvOrDefault("POSTGRESQL_DSN", ""),

      
        
        31
        +		AppEnv:       getenvOrDefault("APP_ENV", "debug"),

      
        
        32
        +		ServerPort:   getenvOrDefault("SERVER_PORT", "3000"),

      
        
        33
        +		PostgresDSN:  getenvOrDefault("POSTGRESQL_DSN", ""),

      
        
        34
        +		PasswordSalt: getenvOrDefault("PASSWORD_SALT", ""),

      
        
        35
        +

      
        
        36
        +		JwtSigningKey: getenvOrDefault("JWT_SIGNING_KEY", ""),

      
        
        37
        +		JwtAccessTokenTTL: mustParseDurationOrPanic(

      
        
        38
        +			getenvOrDefault("JWT_ACCESS_TOKEN_TTL", "15m"),

      
        
        39
        +		),

      
        
        40
        +		JwtRefreshTokenTTL: mustParseDurationOrPanic(

      
        
        41
        +			getenvOrDefault("JWT_REFRESH_TOKEN_TTL", "24h"),

      
        
        42
        +		),

      
        
        43
        +

      
        
        44
        +		MailgunFrom:   getenvOrDefault("MAILGUN_FROM", ""),

      
        
        45
        +		MailgunDomain: getenvOrDefault("MAILGUN_DOMAIN", ""),

      
        
        46
        +		MailgunAPIKey: getenvOrDefault("MAILGUN_API_KEY", ""),

      
        
        47
        +		VerficationTokenTTL: mustParseDurationOrPanic(

      
        
        48
        +			getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h"),

      
        
        49
        +		),

      
        
        50
        +

      
        
        51
        +		LogLevel:    getenvOrDefault("LOG_LEVEL", "debug"),

      
        
        52
        +		LogFormat:   getenvOrDefault("LOG_FORMAT", "json"),

      
        
        53
        +		LogShowLine: getenvOrDefault("LOG_SHOW_LINE", "true") == "true",

      
        34
        54
         	}

      
        35
        55
         }

      
        36
        56
         

      ···
        45
        65
         	return def

      
        46
        66
         }

      
        47
        67
         

      
        48
        
        -func mustParseDuration(dur string) time.Duration {

      
        49
        
        -	d, _ := time.ParseDuration(dur)

      
        
        68
        +func mustParseDurationOrPanic(dur string) time.Duration {

      
        
        69
        +	d, err := time.ParseDuration(dur)

      
        
        70
        +	if err != nil {

      
        
        71
        +		panic(errors.Join(errors.New("cannot time.ParseDuration"), err))

      
        
        72
        +	}

      
        
        73
        +

      
        50
        74
         	return d

      
        51
        75
         }

      
M internal/dtos/user.go
···
        11
        11
         	Username    string

      
        12
        12
         	Email       string

      
        13
        13
         	Password    string

      
        
        14
        +	Activated   bool

      
        14
        15
         	CreatedAt   time.Time

      
        15
        16
         	LastLoginAt time.Time

      
        
        17
        +}

      
        
        18
        +

      
        
        19
        +type ResetUserPasswordDTO struct {

      
        
        20
        +	// NOTE: probablbe userID shouldn't be here

      
        
        21
        +	UserID          uuid.UUID

      
        
        22
        +	CurrentPassword string

      
        
        23
        +	NewPassword     string

      
        16
        24
         }

      
        17
        25
         

      
        18
        26
         type CreateUserDTO struct {

      
A internal/mailer/mailer.go
···
        
        1
        +package mailer

      
        
        2
        +

      
        
        3
        +import "context"

      
        
        4
        +

      
        
        5
        +type Mailer interface {

      
        
        6
        +	Send(ctx context.Context, to, subject, content string) error

      
        
        7
        +}

      
A internal/mailer/mailgun.go
···
        
        1
        +package mailer

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"log/slog"

      
        
        6
        +

      
        
        7
        +	"github.com/mailgun/mailgun-go/v4"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +var _ Mailer = (*Mailgun)(nil)

      
        
        11
        +

      
        
        12
        +type Mailgun struct {

      
        
        13
        +	from string

      
        
        14
        +

      
        
        15
        +	mg *mailgun.MailgunImpl

      
        
        16
        +}

      
        
        17
        +

      
        
        18
        +func NewMailgun(from, domain, apiKey string) *Mailgun {

      
        
        19
        +	mg := mailgun.NewMailgun(domain, apiKey)

      
        
        20
        +	return &Mailgun{

      
        
        21
        +		from: from,

      
        
        22
        +		mg:   mg,

      
        
        23
        +	}

      
        
        24
        +}

      
        
        25
        +

      
        
        26
        +func (m *Mailgun) Send(ctx context.Context, to, subject, content string) error {

      
        
        27
        +	msg := m.mg.NewMessage(m.from, subject, "", to)

      
        
        28
        +	msg.SetHtml(content)

      
        
        29
        +

      
        
        30
        +	_, _, err := m.mg.Send(ctx, msg)

      
        
        31
        +

      
        
        32
        +	slog.Info("email sent", "to", to)

      
        
        33
        +	slog.Debug("email sent", "subject", subject, "content", content, "err", err)

      
        
        34
        +

      
        
        35
        +	return err

      
        
        36
        +}

      
A internal/mailer/testing_mailer.go
···
        
        1
        +package mailer

      
        
        2
        +

      
        
        3
        +import "context"

      
        
        4
        +

      
        
        5
        +var _ Mailer = (*TestMailer)(nil)

      
        
        6
        +

      
        
        7
        +type TestMailer struct {

      
        
        8
        +	emails map[string]string

      
        
        9
        +}

      
        
        10
        +

      
        
        11
        +// NewTestMailer create a mailer for tests

      
        
        12
        +// that implementation of Mailer stores all sent email in memory

      
        
        13
        +// to get the last email sent to a specific email use GetLastSentEmailToEmail

      
        
        14
        +func NewTestMailer() *TestMailer {

      
        
        15
        +	return &TestMailer{

      
        
        16
        +		emails: make(map[string]string),

      
        
        17
        +	}

      
        
        18
        +}

      
        
        19
        +

      
        
        20
        +func (t *TestMailer) Send(_ context.Context, to, _, content string) error {

      
        
        21
        +	t.emails[to] = content

      
        
        22
        +	return nil

      
        
        23
        +}

      
        
        24
        +

      
        
        25
        +// GetLastSentEmailToEmail returns the last email sent to a specific email

      
        
        26
        +func (t *TestMailer) GetLastSentEmailToEmail(email string) string {

      
        
        27
        +	return t.emails[email]

      
        
        28
        +}

      
A internal/mailer/testing_mailer_test.go
···
        
        1
        +package mailer

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"testing"

      
        
        6
        +

      
        
        7
        +	"github.com/stretchr/testify/assert"

      
        
        8
        +	"github.com/stretchr/testify/require"

      
        
        9
        +)

      
        
        10
        +

      
        
        11
        +func TestMailer_Send(t *testing.T) {

      
        
        12
        +	m := NewTestMailer()

      
        
        13
        +	assert.Empty(t, m.emails)

      
        
        14
        +

      
        
        15
        +	email := "test@mail.com"

      
        
        16
        +	err := m.Send(context.TODO(), email, "", "content")

      
        
        17
        +	require.NoError(t, err)

      
        
        18
        +

      
        
        19
        +	assert.Equal(t, "content", m.emails[email])

      
        
        20
        +}

      
        
        21
        +

      
        
        22
        +func TestMailer_GetLastSentEmailToEmail(t *testing.T) {

      
        
        23
        +	m := NewTestMailer()

      
        
        24
        +	assert.Empty(t, m.emails)

      
        
        25
        +

      
        
        26
        +	email := "test@mail.com"

      
        
        27
        +	content := "content"

      
        
        28
        +	err := m.Send(context.TODO(), email, "", content)

      
        
        29
        +	require.NoError(t, err)

      
        
        30
        +

      
        
        31
        +	c := m.GetLastSentEmailToEmail(email)

      
        
        32
        +	assert.Equal(t, content, c)

      
        
        33
        +}

      
M internal/models/user.go
···
        11
        11
         var (

      
        12
        12
         	ErrUserEmailIsAlreadyInUse = errors.New("user: email is already in use")

      
        13
        13
         	ErrUsernameIsAlreadyInUse  = errors.New("user: username is already in use")

      
        
        14
        +	ErrUserIsAlreeadyVerified  = errors.New("user: user is already verified")

      
        
        15
        +

      
        
        16
        +	ErrVerificationTokenNotFound = errors.New("user: verification token not found")

      
        
        17
        +	ErrUserIsNotActivated        = errors.New("user: user is not activated")

      
        14
        18
         

      
        15
        19
         	ErrUserNotFound         = errors.New("user: not found")

      
        16
        20
         	ErrUserWrongCredentials = errors.New("user: wrong credentials")

      ···
        20
        24
         	ID          uuid.UUID

      
        21
        25
         	Username    string

      
        22
        26
         	Email       string

      
        
        27
        +	Activated   bool

      
        23
        28
         	Password    string

      
        24
        29
         	CreatedAt   time.Time

      
        25
        30
         	LastLoginAt time.Time

      
A internal/service/usersrv/email.go
···
        
        1
        +package usersrv

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"errors"

      
        
        6
        +	"fmt"

      
        
        7
        +	"log/slog"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +var ErrFailedToSendVerifcationEmail = errors.New("failed to send verification email")

      
        
        11
        +

      
        
        12
        +const (

      
        
        13
        +	verificationEmailSubject = "Onasty: verifiy your email"

      
        
        14
        +	verificationEmailBody    = `To verify your email, please follow this link:

      
        
        15
        +<a href="%[1]s/api/v1/auth/verify/%[2]s">%[1]s/api/v1/auth/verify/%[2]s</a>

      
        
        16
        +<br />

      
        
        17
        +<br />

      
        
        18
        +This link will expire after 24 hours.`

      
        
        19
        +)

      
        
        20
        +

      
        
        21
        +func (u *UserSrv) sendVerificationEmail(

      
        
        22
        +	ctx context.Context,

      
        
        23
        +	cancel context.CancelFunc,

      
        
        24
        +	userEmail string,

      
        
        25
        +	token string,

      
        
        26
        +) error {

      
        
        27
        +	select {

      
        
        28
        +	case <-ctx.Done():

      
        
        29
        +		slog.Error("failed to send verfication email", "err", ctx.Err())

      
        
        30
        +		return ErrFailedToSendVerifcationEmail

      
        
        31
        +	default:

      
        
        32
        +		if err := u.mailer.Send(

      
        
        33
        +			ctx,

      
        
        34
        +			userEmail,

      
        
        35
        +			verificationEmailSubject,

      
        
        36
        +			// TODO: set proper url

      
        
        37
        +			fmt.Sprintf(verificationEmailBody, "http://localhost:3000", token),

      
        
        38
        +		); err != nil {

      
        
        39
        +			return errors.Join(ErrFailedToSendVerifcationEmail, err)

      
        
        40
        +		}

      
        
        41
        +		cancel()

      
        
        42
        +

      
        
        43
        +		slog.Debug("email sent")

      
        
        44
        +	}

      
        
        45
        +

      
        
        46
        +	return nil

      
        
        47
        +}

      
M internal/service/usersrv/usersrv.go
···
        9
        9
         	"github.com/olexsmir/onasty/internal/dtos"

      
        10
        10
         	"github.com/olexsmir/onasty/internal/hasher"

      
        11
        11
         	"github.com/olexsmir/onasty/internal/jwtutil"

      
        
        12
        +	"github.com/olexsmir/onasty/internal/mailer"

      
        12
        13
         	"github.com/olexsmir/onasty/internal/models"

      
        13
        14
         	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"

      
        14
        15
         	"github.com/olexsmir/onasty/internal/store/psql/userepo"

      
        
        16
        +	"github.com/olexsmir/onasty/internal/store/psql/vertokrepo"

      
        15
        17
         )

      
        16
        18
         

      
        17
        19
         type UserServicer interface {

      ···
        20
        22
         	RefreshTokens(ctx context.Context, refreshToken string) (dtos.TokensDTO, error)

      
        21
        23
         	Logout(ctx context.Context, userID uuid.UUID) error

      
        22
        24
         

      
        23
        
        -	ParseToken(token string) (jwtutil.Payload, error)

      
        
        25
        +	ChangePassword(ctx context.Context, inp dtos.ResetUserPasswordDTO) error

      
        
        26
        +

      
        
        27
        +	Verify(ctx context.Context, verificationKey string) error

      
        
        28
        +	ResendVerificationEmail(ctx context.Context, credentials dtos.SignInDTO) error

      
        
        29
        +

      
        
        30
        +	ParseJWTToken(token string) (jwtutil.Payload, error)

      
        
        31
        +

      
        24
        32
         	CheckIfUserExists(ctx context.Context, userID uuid.UUID) (bool, error)

      
        
        33
        +	CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error)

      
        25
        34
         }

      
        26
        35
         

      
        27
        36
         var _ UserServicer = (*UserSrv)(nil)

      ···
        29
        38
         type UserSrv struct {

      
        30
        39
         	userstore    userepo.UserStorer

      
        31
        40
         	sessionstore sessionrepo.SessionStorer

      
        
        41
        +	vertokrepo   vertokrepo.VerificationTokenStorer

      
        32
        42
         	hasher       hasher.Hasher

      
        33
        43
         	jwtTokenizer jwtutil.JWTTokenizer

      
        
        44
        +	mailer       mailer.Mailer

      
        34
        45
         

      
        35
        
        -	refreshTokenExpiredAt time.Time

      
        
        46
        +	refreshTokenTTL      time.Duration

      
        
        47
        +	verificationTokenTTL time.Duration

      
        36
        48
         }

      
        37
        49
         

      
        38
        50
         func New(

      
        39
        51
         	userstore userepo.UserStorer,

      
        40
        52
         	sessionstore sessionrepo.SessionStorer,

      
        
        53
        +	vertokrepo vertokrepo.VerificationTokenStorer,

      
        41
        54
         	hasher hasher.Hasher,

      
        42
        55
         	jwtTokenizer jwtutil.JWTTokenizer,

      
        
        56
        +	mailer mailer.Mailer,

      
        
        57
        +	refreshTokenTTL, verificationTokenTTL time.Duration,

      
        43
        58
         ) UserServicer {

      
        44
        59
         	return &UserSrv{

      
        45
        
        -		userstore:    userstore,

      
        46
        
        -		sessionstore: sessionstore,

      
        47
        
        -		hasher:       hasher,

      
        48
        
        -		jwtTokenizer: jwtTokenizer,

      
        
        60
        +		userstore:            userstore,

      
        
        61
        +		sessionstore:         sessionstore,

      
        
        62
        +		vertokrepo:           vertokrepo,

      
        
        63
        +		hasher:               hasher,

      
        
        64
        +		jwtTokenizer:         jwtTokenizer,

      
        
        65
        +		mailer:               mailer,

      
        
        66
        +		refreshTokenTTL:      refreshTokenTTL,

      
        
        67
        +		verificationTokenTTL: verificationTokenTTL,

      
        49
        68
         	}

      
        50
        69
         }

      
        51
        70
         

      ···
        55
        74
         		return uuid.UUID{}, err

      
        56
        75
         	}

      
        57
        76
         

      
        58
        
        -	return u.userstore.Create(ctx, dtos.CreateUserDTO{

      
        
        77
        +	uid, err := u.userstore.Create(ctx, dtos.CreateUserDTO{

      
        59
        78
         		Username:    inp.Username,

      
        60
        79
         		Email:       inp.Email,

      
        61
        80
         		Password:    hashedPassword,

      
        62
        81
         		CreatedAt:   inp.CreatedAt,

      
        63
        82
         		LastLoginAt: inp.LastLoginAt,

      
        64
        83
         	})

      
        
        84
        +	if err != nil {

      
        
        85
        +		return uuid.Nil, err

      
        
        86
        +	}

      
        
        87
        +

      
        
        88
        +	vtok := uuid.Must(uuid.NewV4()).String()

      
        
        89
        +	if err := u.vertokrepo.Create(ctx, vtok, uid, time.Now(), time.Now().Add(u.verificationTokenTTL)); err != nil {

      
        
        90
        +		return uuid.Nil, err

      
        
        91
        +	}

      
        
        92
        +

      
        
        93
        +	// TODO: handle the error that might be returned

      
        
        94
        +	// i dont think that tehre's need to handle the error, just log it

      
        
        95
        +	bgCtx, bgCancel := context.WithTimeout(context.Background(), 10*time.Second)

      
        
        96
        +	go u.sendVerificationEmail(bgCtx, bgCancel, inp.Email, vtok) //nolint:errcheck

      
        
        97
        +

      
        
        98
        +	return uid, nil

      
        65
        99
         }

      
        66
        100
         

      
        67
        101
         func (u *UserSrv) SignIn(ctx context.Context, inp dtos.SignInDTO) (dtos.TokensDTO, error) {

      ···
        78
        112
         		return dtos.TokensDTO{}, err

      
        79
        113
         	}

      
        80
        114
         

      
        
        115
        +	if !user.Activated {

      
        
        116
        +		return dtos.TokensDTO{}, models.ErrUserIsNotActivated

      
        
        117
        +	}

      
        
        118
        +

      
        81
        119
         	tokens, err := u.getTokens(user.ID)

      
        82
        120
         	if err != nil {

      
        83
        121
         		return dtos.TokensDTO{}, err

      
        84
        122
         	}

      
        85
        123
         

      
        86
        
        -	if err := u.sessionstore.Set(ctx, user.ID, tokens.Refresh, u.refreshTokenExpiredAt); err != nil {

      
        
        124
        +	if err := u.sessionstore.Set(ctx, user.ID, tokens.Refresh, time.Now().Add(u.refreshTokenTTL)); err != nil {

      
        87
        125
         		return dtos.TokensDTO{}, err

      
        88
        126
         	}

      
        89
        127
         

      ···
        108
        146
         		return dtos.TokensDTO{}, err

      
        109
        147
         	}

      
        110
        148
         

      
        111
        
        -	err = u.sessionstore.Update(ctx, userID, rtoken, tokens.Refresh)

      
        
        149
        +	if err := u.sessionstore.Update(ctx, userID, rtoken, tokens.Refresh); err != nil {

      
        
        150
        +		return dtos.TokensDTO{}, err

      
        
        151
        +	}

      
        112
        152
         

      
        113
        153
         	return dtos.TokensDTO{

      
        114
        154
         		Access:  tokens.Access,

      
        115
        155
         		Refresh: tokens.Refresh,

      
        116
        
        -	}, err

      
        
        156
        +	}, nil

      
        117
        157
         }

      
        118
        158
         

      
        119
        
        -func (u *UserSrv) ParseToken(token string) (jwtutil.Payload, error) {

      
        
        159
        +func (u *UserSrv) ChangePassword(ctx context.Context, inp dtos.ResetUserPasswordDTO) error {

      
        
        160
        +	oldPass, err := u.hasher.Hash(inp.CurrentPassword)

      
        
        161
        +	if err != nil {

      
        
        162
        +		return err

      
        
        163
        +	}

      
        
        164
        +

      
        
        165
        +	newPass, err := u.hasher.Hash(inp.NewPassword)

      
        
        166
        +	if err != nil {

      
        
        167
        +		return err

      
        
        168
        +	}

      
        
        169
        +

      
        
        170
        +	if err := u.userstore.ChangePassword(ctx, inp.UserID, oldPass, newPass); err != nil {

      
        
        171
        +		return err

      
        
        172
        +	}

      
        
        173
        +

      
        
        174
        +	return nil

      
        
        175
        +}

      
        
        176
        +

      
        
        177
        +func (u *UserSrv) Verify(ctx context.Context, verificationKey string) error {

      
        
        178
        +	uid, err := u.vertokrepo.GetUserIDByTokenAndMarkAsUsed(ctx, verificationKey, time.Now())

      
        
        179
        +	if err != nil {

      
        
        180
        +		return err

      
        
        181
        +	}

      
        
        182
        +

      
        
        183
        +	return u.userstore.MarkUserAsActivated(ctx, uid)

      
        
        184
        +}

      
        
        185
        +

      
        
        186
        +func (u *UserSrv) ResendVerificationEmail(ctx context.Context, inp dtos.SignInDTO) error {

      
        
        187
        +	hashedPassword, err := u.hasher.Hash(inp.Password)

      
        
        188
        +	if err != nil {

      
        
        189
        +		return err

      
        
        190
        +	}

      
        
        191
        +

      
        
        192
        +	user, err := u.userstore.GetUserByCredentials(ctx, inp.Email, hashedPassword)

      
        
        193
        +	if err != nil {

      
        
        194
        +		if errors.Is(err, models.ErrUserNotFound) {

      
        
        195
        +			return models.ErrUserWrongCredentials

      
        
        196
        +		}

      
        
        197
        +		return err

      
        
        198
        +	}

      
        
        199
        +

      
        
        200
        +	if user.Activated {

      
        
        201
        +		return models.ErrUserIsAlreeadyVerified

      
        
        202
        +	}

      
        
        203
        +

      
        
        204
        +	token, err := u.vertokrepo.GetTokenOrUpdateTokenByUserID(

      
        
        205
        +		ctx,

      
        
        206
        +		user.ID,

      
        
        207
        +		uuid.Must(uuid.NewV4()).String(),

      
        
        208
        +		time.Now().Add(u.verificationTokenTTL))

      
        
        209
        +	if err != nil {

      
        
        210
        +		return err

      
        
        211
        +	}

      
        
        212
        +

      
        
        213
        +	bgCtx, bgCancel := context.WithTimeout(context.Background(), 10*time.Second)

      
        
        214
        +	go u.sendVerificationEmail(bgCtx, bgCancel, inp.Email, token) //nolint:errcheck

      
        
        215
        +

      
        
        216
        +	return nil

      
        
        217
        +}

      
        
        218
        +

      
        
        219
        +func (u *UserSrv) ParseJWTToken(token string) (jwtutil.Payload, error) {

      
        120
        220
         	return u.jwtTokenizer.Parse(token)

      
        121
        221
         }

      
        122
        222
         

      
        123
        223
         func (u UserSrv) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) {

      
        124
        224
         	return u.userstore.CheckIfUserExists(ctx, id)

      
        
        225
        +}

      
        
        226
        +

      
        
        227
        +func (u UserSrv) CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error) {

      
        
        228
        +	return u.userstore.CheckIfUserIsActivated(ctx, userID)

      
        125
        229
         }

      
        126
        230
         

      
        127
        231
         func (u UserSrv) getTokens(userID uuid.UUID) (dtos.TokensDTO, error) {

      
M internal/store/psql/sessionrepo/sessionrepo.go
···
        62
        62
         where

      
        63
        63
           user_id = $2

      
        64
        64
           and refresh_token = $3

      
        65
        
        -  and expires_at < now()

      
        
        65
        +  -- and expires_at < now()

      
        66
        66
         `

      
        67
        67
         

      
        68
        68
         	res, err := s.db.Exec(ctx, query, newRefreshToken, userID, refreshToken)

      
M internal/store/psql/userepo/userepo.go
···
        14
        14
         

      
        15
        15
         type UserStorer interface {

      
        16
        16
         	Create(ctx context.Context, inp dtos.CreateUserDTO) (uuid.UUID, error)

      
        
        17
        +

      
        
        18
        +	// GetUserByCredentials returns user by email and password

      
        
        19
        +	// password should be hashed

      
        17
        20
         	GetUserByCredentials(ctx context.Context, email, password string) (dtos.UserDTO, error)

      
        18
        21
         

      
        19
        
        -	CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error)

      
        
        22
        +	GetUserIDByEmail(ctx context.Context, email string) (uuid.UUID, error)

      
        
        23
        +	MarkUserAsActivated(ctx context.Context, id uuid.UUID) error

      
        
        24
        +

      
        
        25
        +	// ChangePassword changes user password from oldPassword to newPassword

      
        
        26
        +	// and oldPassword and newPassword should be hashed

      
        
        27
        +	ChangePassword(ctx context.Context, userID uuid.UUID, oldPassword, newPassword string) error

      
        
        28
        +

      
        
        29
        +	// SetPassword sets new password for user by their id

      
        
        30
        +	// password should be hashed

      
        
        31
        +	SetPassword(ctx context.Context, userID uuid.UUID, newPassword string) error

      
        
        32
        +

      
        
        33
        +	CheckIfUserExists(ctx context.Context, userID uuid.UUID) (bool, error)

      
        
        34
        +	CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error)

      
        20
        35
         }

      
        21
        36
         

      
        22
        37
         var _ UserStorer = (*UserRepo)(nil)

      ···
        62
        77
         	email, password string,

      
        63
        78
         ) (dtos.UserDTO, error) {

      
        64
        79
         	query, args, err := pgq.

      
        65
        
        -		Select("id", "username", "email", "password", "created_at", "last_login_at").

      
        
        80
        +		Select("id", "username", "email", "password", "activated", "created_at", "last_login_at").

      
        66
        81
         		From("users").

      
        67
        82
         		Where(pgq.Eq{

      
        68
        83
         			"email":    email,

      ···
        75
        90
         

      
        76
        91
         	var user dtos.UserDTO

      
        77
        92
         	err = r.db.QueryRow(ctx, query, args...).

      
        78
        
        -		Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.LastLoginAt)

      
        
        93
        +		Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.Activated, &user.CreatedAt, &user.LastLoginAt)

      
        79
        94
         	if errors.Is(err, pgx.ErrNoRows) {

      
        80
        95
         		return dtos.UserDTO{}, models.ErrUserNotFound

      
        81
        96
         	}

      ···
        83
        98
         	return user, err

      
        84
        99
         }

      
        85
        100
         

      
        
        101
        +func (r *UserRepo) GetUserIDByEmail(ctx context.Context, email string) (uuid.UUID, error) {

      
        
        102
        +	query, args, err := pgq.

      
        
        103
        +		Select("id").

      
        
        104
        +		From("users").

      
        
        105
        +		Where(pgq.Eq{"email": email}).

      
        
        106
        +		SQL()

      
        
        107
        +	if err != nil {

      
        
        108
        +		return uuid.Nil, err

      
        
        109
        +	}

      
        
        110
        +

      
        
        111
        +	var id uuid.UUID

      
        
        112
        +	err = r.db.QueryRow(ctx, query, args...).Scan(&id)

      
        
        113
        +	if errors.Is(err, pgx.ErrNoRows) {

      
        
        114
        +		return uuid.Nil, models.ErrUserNotFound

      
        
        115
        +	}

      
        
        116
        +

      
        
        117
        +	return id, err

      
        
        118
        +}

      
        
        119
        +

      
        
        120
        +func (r *UserRepo) MarkUserAsActivated(ctx context.Context, id uuid.UUID) error {

      
        
        121
        +	query, args, err := pgq.

      
        
        122
        +		Update("users").

      
        
        123
        +		Set("activated ", true).

      
        
        124
        +		Where(pgq.Eq{"id": id.String()}).

      
        
        125
        +		SQL()

      
        
        126
        +	if err != nil {

      
        
        127
        +		return err

      
        
        128
        +	}

      
        
        129
        +

      
        
        130
        +	_, err = r.db.Exec(ctx, query, args...)

      
        
        131
        +	return err

      
        
        132
        +}

      
        
        133
        +

      
        
        134
        +func (r *UserRepo) ChangePassword(

      
        
        135
        +	ctx context.Context,

      
        
        136
        +	userID uuid.UUID,

      
        
        137
        +	oldPass, newPass string,

      
        
        138
        +) error {

      
        
        139
        +	query, args, err := pgq.

      
        
        140
        +		Update("users").

      
        
        141
        +		Set("password", newPass).

      
        
        142
        +		Where(pgq.Eq{

      
        
        143
        +			"id":       userID.String(),

      
        
        144
        +			"password": oldPass,

      
        
        145
        +		}).

      
        
        146
        +		SQL()

      
        
        147
        +	if err != nil {

      
        
        148
        +		return err

      
        
        149
        +	}

      
        
        150
        +	_, err = r.db.Exec(ctx, query, args...)

      
        
        151
        +	return err

      
        
        152
        +}

      
        
        153
        +

      
        
        154
        +func (r *UserRepo) SetPassword(ctx context.Context, userID uuid.UUID, password string) error {

      
        
        155
        +	query, args, err := pgq.

      
        
        156
        +		Update("users").

      
        
        157
        +		Set("password", password).

      
        
        158
        +		Where(pgq.Eq{"id": userID.String()}).

      
        
        159
        +		SQL()

      
        
        160
        +	if err != nil {

      
        
        161
        +		return err

      
        
        162
        +	}

      
        
        163
        +

      
        
        164
        +	_, err = r.db.Exec(ctx, query, args...)

      
        
        165
        +	return err

      
        
        166
        +}

      
        
        167
        +

      
        86
        168
         func (r *UserRepo) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) {

      
        87
        169
         	var exists bool

      
        88
        170
         	err := r.db.QueryRow(

      ···
        96
        178
         

      
        97
        179
         	return exists, err

      
        98
        180
         }

      
        
        181
        +

      
        
        182
        +func (r *UserRepo) CheckIfUserIsActivated(ctx context.Context, id uuid.UUID) (bool, error) {

      
        
        183
        +	var activated bool

      
        
        184
        +	err := r.db.QueryRow(ctx, `SELECT activated FROM users WHERE id = $1`, id.String()).

      
        
        185
        +		Scan(&activated)

      
        
        186
        +	if errors.Is(err, pgx.ErrNoRows) {

      
        
        187
        +		return false, models.ErrUserNotFound

      
        
        188
        +	}

      
        
        189
        +	return activated, err

      
        
        190
        +}

      
A internal/store/psql/vertokrepo/vertokrepo.go
···
        
        1
        +package vertokrepo

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"time"

      
        
        6
        +

      
        
        7
        +	"github.com/gofrs/uuid/v5"

      
        
        8
        +	"github.com/henvic/pgq"

      
        
        9
        +	"github.com/olexsmir/onasty/internal/models"

      
        
        10
        +	"github.com/olexsmir/onasty/internal/store/psqlutil"

      
        
        11
        +)

      
        
        12
        +

      
        
        13
        +type VerificationTokenStorer interface {

      
        
        14
        +	Create(

      
        
        15
        +		ctx context.Context,

      
        
        16
        +		token string,

      
        
        17
        +		userID uuid.UUID,

      
        
        18
        +		createdAt, expiresAt time.Time,

      
        
        19
        +	) error

      
        
        20
        +

      
        
        21
        +	GetUserIDByTokenAndMarkAsUsed(

      
        
        22
        +		ctx context.Context,

      
        
        23
        +		token string,

      
        
        24
        +		usedAT time.Time,

      
        
        25
        +	) (uuid.UUID, error)

      
        
        26
        +

      
        
        27
        +	GetTokenOrUpdateTokenByUserID(

      
        
        28
        +		ctx context.Context,

      
        
        29
        +		userID uuid.UUID,

      
        
        30
        +		token string,

      
        
        31
        +		tokenExpirationTime time.Time,

      
        
        32
        +	) (string, error)

      
        
        33
        +}

      
        
        34
        +

      
        
        35
        +var _ VerificationTokenStorer = (*VerificationTokenRepo)(nil)

      
        
        36
        +

      
        
        37
        +type VerificationTokenRepo struct {

      
        
        38
        +	db *psqlutil.DB

      
        
        39
        +}

      
        
        40
        +

      
        
        41
        +func New(db *psqlutil.DB) *VerificationTokenRepo {

      
        
        42
        +	return &VerificationTokenRepo{

      
        
        43
        +		db: db,

      
        
        44
        +	}

      
        
        45
        +}

      
        
        46
        +

      
        
        47
        +func (r *VerificationTokenRepo) Create(

      
        
        48
        +	ctx context.Context,

      
        
        49
        +	token string,

      
        
        50
        +	userID uuid.UUID,

      
        
        51
        +	createdAt, expiresAt time.Time,

      
        
        52
        +) error {

      
        
        53
        +	query, aggs, err := pgq.

      
        
        54
        +		Insert("verification_tokens").

      
        
        55
        +		Columns("user_id", "token", "created_at", "expires_at").

      
        
        56
        +		Values(userID, token, createdAt, expiresAt).

      
        
        57
        +		SQL()

      
        
        58
        +	if err != nil {

      
        
        59
        +		return err

      
        
        60
        +	}

      
        
        61
        +

      
        
        62
        +	_, err = r.db.Exec(ctx, query, aggs...)

      
        
        63
        +	return err

      
        
        64
        +}

      
        
        65
        +

      
        
        66
        +func (r *VerificationTokenRepo) GetUserIDByTokenAndMarkAsUsed(

      
        
        67
        +	ctx context.Context,

      
        
        68
        +	token string,

      
        
        69
        +	usedAt time.Time,

      
        
        70
        +) (uuid.UUID, error) {

      
        
        71
        +	tx, err := r.db.Begin(ctx)

      
        
        72
        +	if err != nil {

      
        
        73
        +		return uuid.Nil, err

      
        
        74
        +	}

      
        
        75
        +	defer tx.Rollback(ctx) //nolint:errcheck

      
        
        76
        +

      
        
        77
        +	var isUsed bool

      
        
        78
        +	err = tx.QueryRow(ctx, "select used_at is not null from verification_tokens where token = $1", token).

      
        
        79
        +		Scan(&isUsed)

      
        
        80
        +	if err != nil {

      
        
        81
        +		return uuid.Nil, err

      
        
        82
        +	}

      
        
        83
        +

      
        
        84
        +	if isUsed {

      
        
        85
        +		return uuid.Nil, models.ErrUserIsAlreeadyVerified

      
        
        86
        +	}

      
        
        87
        +

      
        
        88
        +	query := `--sql

      
        
        89
        +update verification_tokens

      
        
        90
        +set used_at = $1

      
        
        91
        +where token = $2

      
        
        92
        +returning user_id`

      
        
        93
        +

      
        
        94
        +	var userID uuid.UUID

      
        
        95
        +	err = tx.QueryRow(ctx, query, usedAt, token).Scan(&userID)

      
        
        96
        +	if err != nil {

      
        
        97
        +		return uuid.Nil, err

      
        
        98
        +	}

      
        
        99
        +

      
        
        100
        +	return userID, tx.Commit(ctx)

      
        
        101
        +}

      
        
        102
        +

      
        
        103
        +func (r *VerificationTokenRepo) GetTokenOrUpdateTokenByUserID(

      
        
        104
        +	ctx context.Context,

      
        
        105
        +	userID uuid.UUID,

      
        
        106
        +	token string,

      
        
        107
        +	tokenExpirationTime time.Time,

      
        
        108
        +) (string, error) {

      
        
        109
        +	query := `--sql

      
        
        110
        +insert into verification_tokens (user_id, token, expires_at)

      
        
        111
        +values ($1, $2, $3)

      
        
        112
        +on conflict (user_id)

      
        
        113
        +  do update set

      
        
        114
        +    token = $2,

      
        
        115
        +    expires_at = $3

      
        
        116
        +returning token`

      
        
        117
        +

      
        
        118
        +	var res string

      
        
        119
        +	err := r.db.QueryRow(ctx, query, userID, token, tokenExpirationTime).Scan(&res)

      
        
        120
        +	return res, err

      
        
        121
        +}

      
M internal/store/psqlutil/psqlutil.go
···
        4
        4
         	"context"

      
        5
        5
         	"errors"

      
        6
        6
         

      
        7
        
        -	"github.com/jackc/pgconn"

      
        8
        7
         	pgxuuid "github.com/jackc/pgx-gofrs-uuid"

      
        9
        8
         	"github.com/jackc/pgx/v5"

      
        
        9
        +	"github.com/jackc/pgx/v5/pgconn"

      
        10
        10
         	"github.com/jackc/pgx/v5/pgxpool"

      
        11
        11
         )

      
        12
        12
         

      
M internal/transport/http/apiv1/apiv1.go
···
        27
        27
         		auth.POST("/signup", a.signUpHandler)

      
        28
        28
         		auth.POST("/signin", a.signInHandler)

      
        29
        29
         		auth.POST("/refresh-tokens", a.refreshTokensHandler)

      
        
        30
        +		auth.GET("/verify/:token", a.verifyHandler)

      
        
        31
        +		auth.POST("/resend-verification-email", a.resendVerificationEmailHandler)

      
        30
        32
         

      
        31
        33
         		authorized := auth.Group("/", a.authorizedMiddleware)

      
        32
        34
         		{

      
        33
        35
         			authorized.POST("/logout", a.logOutHandler)

      
        
        36
        +			authorized.POST("/change-password", a.changePasswordHandler)

      
        34
        37
         		}

      
        35
        38
         	}

      
        36
        39
         

      
M internal/transport/http/apiv1/auth.go
···
        104
        104
         	})

      
        105
        105
         }

      
        106
        106
         

      
        
        107
        +func (a *APIV1) verifyHandler(c *gin.Context) {

      
        
        108
        +	if err := a.usersrv.Verify(c.Request.Context(), c.Param("token")); err != nil {

      
        
        109
        +		errorResponse(c, err)

      
        
        110
        +		return

      
        
        111
        +	}

      
        
        112
        +

      
        
        113
        +	c.String(http.StatusOK, "email verified")

      
        
        114
        +}

      
        
        115
        +

      
        
        116
        +func (a *APIV1) resendVerificationEmailHandler(c *gin.Context) {

      
        
        117
        +	var req signInRequest

      
        
        118
        +	if err := c.ShouldBindJSON(&req); err != nil {

      
        
        119
        +		newError(c, http.StatusBadRequest, "invalid request")

      
        
        120
        +		return

      
        
        121
        +	}

      
        
        122
        +

      
        
        123
        +	if err := a.usersrv.ResendVerificationEmail(c.Request.Context(), dtos.SignInDTO{

      
        
        124
        +		Email:    req.Email,

      
        
        125
        +		Password: req.Password,

      
        
        126
        +	}); err != nil {

      
        
        127
        +		errorResponse(c, err)

      
        
        128
        +		return

      
        
        129
        +	}

      
        
        130
        +

      
        
        131
        +	c.Status(http.StatusOK)

      
        
        132
        +}

      
        
        133
        +

      
        107
        134
         func (a *APIV1) logOutHandler(c *gin.Context) {

      
        108
        135
         	if err := a.usersrv.Logout(c.Request.Context(), a.getUserID(c)); err != nil {

      
        109
        136
         		errorResponse(c, err)

      ···
        112
        139
         

      
        113
        140
         	c.Status(http.StatusNoContent)

      
        114
        141
         }

      
        
        142
        +

      
        
        143
        +type changePasswordRequest struct {

      
        
        144
        +	CurrentPassword string `json:"current_password"`

      
        
        145
        +	NewPassword     string `json:"new_password"`

      
        
        146
        +}

      
        
        147
        +

      
        
        148
        +func (a *APIV1) changePasswordHandler(c *gin.Context) {

      
        
        149
        +	var req changePasswordRequest

      
        
        150
        +	if err := c.ShouldBindJSON(&req); err != nil {

      
        
        151
        +		newError(c, http.StatusBadRequest, "invalid request")

      
        
        152
        +		return

      
        
        153
        +	}

      
        
        154
        +

      
        
        155
        +	if err := a.usersrv.ChangePassword(

      
        
        156
        +		c.Request.Context(),

      
        
        157
        +		dtos.ResetUserPasswordDTO{

      
        
        158
        +			UserID:          a.getUserID(c),

      
        
        159
        +			CurrentPassword: req.CurrentPassword,

      
        
        160
        +			NewPassword:     req.NewPassword,

      
        
        161
        +		}); err != nil {

      
        
        162
        +		errorResponse(c, err)

      
        
        163
        +		return

      
        
        164
        +	}

      
        
        165
        +

      
        
        166
        +	c.Status(http.StatusOK)

      
        
        167
        +}

      
M internal/transport/http/apiv1/middleware.go
···
        2
        2
         

      
        3
        3
         import (

      
        4
        4
         	"context"

      
        5
        
        -	"errors"

      
        6
        5
         	"strings"

      
        7
        6
         

      
        8
        7
         	"github.com/gin-gonic/gin"

      
        9
        8
         	"github.com/gofrs/uuid/v5"

      
        10
        
        -	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        
        9
        +	"github.com/olexsmir/onasty/internal/models"

      
        11
        10
         )

      
        12
        11
         

      
        13
        
        -var ErrUnauthorized = errors.New("unauthorized")

      
        14
        
        -

      
        15
        12
         const userIDCtxKey = "userID"

      
        16
        13
         

      
        
        14
        +// authorizedMiddleware is a middleware that checks if user is authorized

      
        
        15
        +// and if so sets user metadata to context

      
        
        16
        +//

      
        
        17
        +// being authorized is required for making the request for specific endpoint

      
        17
        18
         func (a *APIV1) authorizedMiddleware(c *gin.Context) {

      
        18
        19
         	token, ok := getTokenFromAuthHeaders(c)

      
        19
        20
         	if !ok {

      ···
        21
        22
         		return

      
        22
        23
         	}

      
        23
        24
         

      
        24
        
        -	ok, err := checkIfUserIsReal(c.Request.Context(), token, a.usersrv)

      
        
        25
        +	uid, err := a.validateAuthorizedUser(c.Request.Context(), token)

      
        25
        26
         	if err != nil {

      
        26
        27
         		errorResponse(c, err)

      
        27
        28
         		return

      
        28
        29
         	}

      
        29
        30
         

      
        30
        
        -	if !ok {

      
        31
        
        -		errorResponse(c, ErrUnauthorized)

      
        32
        
        -		return

      
        33
        
        -	}

      
        34
        
        -

      
        35
        
        -	if err := saveUserIDToCtx(c, a.usersrv, token); err != nil {

      
        36
        
        -		errorResponse(c, err)

      
        37
        
        -		return

      
        38
        
        -	}

      
        
        31
        +	c.Set(userIDCtxKey, uid)

      
        39
        32
         

      
        40
        33
         	c.Next()

      
        41
        34
         }

      
        42
        35
         

      
        
        36
        +// couldBeAuthorizedMiddleware is a middleware that checks if user is authorized and

      
        
        37
        +// if so sets user metadata to context

      
        
        38
        +//

      
        
        39
        +// it is NOT required to be authorized for making the request for specific endpoint

      
        43
        40
         func (a *APIV1) couldBeAuthorizedMiddleware(c *gin.Context) {

      
        44
        41
         	token, ok := getTokenFromAuthHeaders(c)

      
        45
        42
         	if ok {

      
        46
        
        -		ok, err := checkIfUserIsReal(c.Request.Context(), token, a.usersrv)

      
        
        43
        +		uid, err := a.validateAuthorizedUser(c.Request.Context(), token)

      
        47
        44
         		if err != nil {

      
        48
        45
         			errorResponse(c, err)

      
        49
        46
         			return

      
        50
        47
         		}

      
        51
        48
         

      
        52
        
        -		if !ok {

      
        53
        
        -			errorResponse(c, ErrUnauthorized)

      
        54
        
        -			return

      
        55
        
        -		}

      
        56
        
        -

      
        57
        
        -		if err := saveUserIDToCtx(c, a.usersrv, token); err != nil {

      
        58
        
        -			newInternalError(c, err)

      
        59
        
        -			return

      
        60
        
        -		}

      
        
        49
        +		c.Set(userIDCtxKey, uid)

      
        61
        50
         	}

      
        62
        51
         

      
        63
        52
         	c.Next()

      ···
        86
        75
         	return headerParts[1], true

      
        87
        76
         }

      
        88
        77
         

      
        89
        
        -func saveUserIDToCtx(c *gin.Context, us usersrv.UserServicer, token string) error {

      
        90
        
        -	pl, err := us.ParseToken(token)

      
        91
        
        -	if err != nil {

      
        92
        
        -		return err

      
        93
        
        -	}

      
        94
        
        -

      
        95
        
        -	c.Set(userIDCtxKey, pl.UserID)

      
        96
        
        -

      
        97
        
        -	return nil

      
        98
        
        -}

      
        99
        
        -

      
        100
        78
         // getUserId returns userId from the context

      
        101
        79
         // getting user id is only possible if user is authorized

      
        102
        80
         func (a *APIV1) getUserID(c *gin.Context) uuid.UUID {

      ···
        104
        82
         	if !exists {

      
        105
        83
         		return uuid.Nil

      
        106
        84
         	}

      
        107
        
        -	return uuid.Must(uuid.FromString(userID.(string)))

      
        
        85
        +	return userID.(uuid.UUID)

      
        108
        86
         }

      
        109
        87
         

      
        110
        
        -func checkIfUserIsReal(

      
        111
        
        -	ctx context.Context,

      
        112
        
        -	accessToken string,

      
        113
        
        -	us usersrv.UserServicer,

      
        114
        
        -) (bool, error) {

      
        115
        
        -	parsedToken, err := us.ParseToken(accessToken)

      
        
        88
        +func (a *APIV1) validateAuthorizedUser(ctx context.Context, accessToken string) (uuid.UUID, error) {

      
        
        89
        +	tokenPayload, err := a.usersrv.ParseJWTToken(accessToken)

      
        116
        90
         	if err != nil {

      
        117
        
        -		return false, err

      
        
        91
        +		return uuid.Nil, err

      
        118
        92
         	}

      
        119
        93
         

      
        120
        
        -	return us.CheckIfUserExists(

      
        121
        
        -		ctx,

      
        122
        
        -		uuid.Must(uuid.FromString(parsedToken.UserID)),

      
        123
        
        -	)

      
        
        94
        +	userID := uuid.Must(uuid.FromString(tokenPayload.UserID))

      
        
        95
        +

      
        
        96
        +	ok, err := a.usersrv.CheckIfUserExists(ctx, userID)

      
        
        97
        +	if err != nil {

      
        
        98
        +		return uuid.Nil, err

      
        
        99
        +	}

      
        
        100
        +

      
        
        101
        +	if !ok {

      
        
        102
        +		return uuid.Nil, ErrUnauthorized

      
        
        103
        +	}

      
        
        104
        +

      
        
        105
        +	ok, err = a.usersrv.CheckIfUserIsActivated(ctx, userID)

      
        
        106
        +	if err != nil {

      
        
        107
        +		return uuid.Nil, err

      
        
        108
        +	}

      
        
        109
        +

      
        
        110
        +	if !ok {

      
        
        111
        +		return uuid.Nil, models.ErrUserIsNotActivated

      
        
        112
        +	}

      
        
        113
        +

      
        
        114
        +	return userID, nil

      
        124
        115
         }

      
M internal/transport/http/apiv1/note.go
···
        1
        1
         package apiv1

      
        2
        2
         

      
        3
        3
         import (

      
        4
        
        -	"log/slog"

      
        5
        4
         	"net/http"

      
        6
        5
         	"time"

      
        7
        6
         

      ···
        40
        39
         		newErrorStatus(c, http.StatusBadRequest, err.Error())

      
        41
        40
         		return

      
        42
        41
         	}

      
        43
        
        -

      
        44
        
        -	slog.Debug("userid", "a", a.getUserID(c))

      
        45
        42
         

      
        46
        43
         	slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNoteDTO{

      
        47
        44
         		Content:              note.Content,

      
M internal/transport/http/apiv1/response.go
···
        9
        9
         	"github.com/olexsmir/onasty/internal/models"

      
        10
        10
         )

      
        11
        11
         

      
        
        12
        +var ErrUnauthorized = errors.New("unauthorized")

      
        
        13
        +

      
        12
        14
         type response struct {

      
        13
        15
         	Message string `json:"message"`

      
        14
        16
         }

      ···
        17
        19
         	if errors.Is(err, models.ErrUserEmailIsAlreadyInUse) ||

      
        18
        20
         		errors.Is(err, models.ErrUsernameIsAlreadyInUse) ||

      
        19
        21
         		errors.Is(err, models.ErrNoteContentIsEmpty) ||

      
        20
        
        -		errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) {

      
        
        22
        +		errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) ||

      
        
        23
        +		errors.Is(err, models.ErrUserIsNotActivated) ||

      
        
        24
        +		errors.Is(err, models.ErrUserIsAlreeadyVerified) {

      
        21
        25
         		newError(c, http.StatusBadRequest, err.Error())

      
        22
        26
         		return

      
        23
        27
         	}

      ···
        27
        31
         		return

      
        28
        32
         	}

      
        29
        33
         

      
        30
        
        -	if errors.Is(err, models.ErrNoteNotFound) {

      
        
        34
        +	if errors.Is(err, models.ErrNoteNotFound) ||

      
        
        35
        +		errors.Is(err, models.ErrVerificationTokenNotFound) {

      
        31
        36
         		newErrorStatus(c, http.StatusNotFound, err.Error())

      
        32
        37
         		return

      
        33
        38
         	}

      
A migrations/20240729115827_verification_tokens.down.sql
···
        
        1
        +drop table verification_tokens;

      
A migrations/20240729115827_verification_tokens.up.sql
···
        
        1
        +create table verification_tokens (

      
        
        2
        +  id uuid primary key default uuid_generate_v4(),

      
        
        3
        +  user_id uuid not null unique references users(id),

      
        
        4
        +  token varchar(255) not null unique,

      
        
        5
        +  created_at timestamptz not null default now(),

      
        
        6
        +  expires_at timestamptz not null,

      
        
        7
        +  used_at timestamptz default null

      
        
        8
        +);