all repos

onasty @ 7236f0c

a one-time notes service
22 files changed, 440 insertions(+), 39 deletions(-)
feat: reset password (#110)

* setup boilerplate

* mailer: add template for password resetting

* feat: add password reset tokens repo

* feat(auth): implement "request password reset"

* fix e2e

* feat(auth): implement password reset

* add some more todo comments

* feat(usersrv): add reset password token ttl to config

* feat(usersrv): validate user password

* refactor: return error if token is already used

* refactor(mailermq): move topic to a var

* fixup! feat(usersrv): validate user password

* fix(auth): typos

* fixup! fix e2e

* fix(usersrv): fix constructor

* fix: naming and typos

* fixup! fix: naming and typos

* fixup! feat: add password reset tokens repo

* test(e2e): check sent emails

* test(e2e): test reset password

* chore: update todo comment

* refactor(passwordtokrepo): use model instead of passing every value by hands

* fix(auth): expired token

* fixup! refactor(passwordtokrepo): use model instead of passing every value by hands
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-05-17 16:25:31 +0300
Parent: 5495cb4
M .env.example
···
        39
        39
         NATS_URL="nats:4222"

      
        40
        40
         

      
        41
        41
         VERIFICATION_TOKEN_TTL=48h

      
        
        42
        +RESET_PASSWORD_TOKEN_TTL=1h

      
        42
        43
         

      
        43
        44
         RATELIMITER_RPS=100

      
        44
        45
         RATELIMITER_BURST=10

      
M Taskfile.yml
···
        8
        8
         

      
        9
        9
         env:

      
        10
        10
           DOCKER_BUILDKIT: 1

      
        
        11
        +  COMPOSE_DOCKER_CLI_BUILD: 1

      
        11
        12
         

      
        12
        13
         tasks:

      
        13
        14
           run:

      
M cmd/server/main.go
···
        21
        21
         	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        22
        22
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        23
        23
         	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

      
        
        24
        +	"github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo"

      
        24
        25
         	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"

      
        25
        26
         	"github.com/olexsmir/onasty/internal/store/psql/userepo"

      
        26
        27
         	"github.com/olexsmir/onasty/internal/store/psql/vertokrepo"

      ···
        95
        96
         

      
        96
        97
         	sessionrepo := sessionrepo.New(psqlDB)

      
        97
        98
         	vertokrepo := vertokrepo.New(psqlDB)

      
        
        99
        +	pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(psqlDB)

      
        98
        100
         

      
        99
        101
         	userepo := userepo.New(psqlDB)

      
        100
        102
         	usercache := usercache.New(redisDB, cfg.CacheUsersTTL)

      ···
        102
        104
         		userepo,

      
        103
        105
         		sessionrepo,

      
        104
        106
         		vertokrepo,

      
        
        107
        +		pwdtokrepo,

      
        105
        108
         		userPasswordHasher,

      
        106
        109
         		jwtTokenizer,

      
        107
        110
         		mailermq,

      ···
        110
        113
         		githubOauth,

      
        111
        114
         		cfg.JwtRefreshTokenTTL,

      
        112
        115
         		cfg.VerificationTokenTTL,

      
        
        116
        +		cfg.ResetPasswordTokenTTL,

      
        113
        117
         	)

      
        114
        118
         

      
        115
        119
         	notecache := notecache.New(redisDB, cfg.CacheNoteTTL)

      
M e2e/apiv1_auth_test.go
···
        102
        102
         

      
        103
        103
         	user := e.getLastUserByEmail(email)

      
        104
        104
         	token := e.getVerificationTokenByUserID(user.ID)

      
        
        105
        +	e.Equal(token.Token, mockMailStore[email])

      
        
        106
        +

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

      
        106
        108
         	e.Equal(http.StatusOK, httpResp.Code)

      
        107
        109
         

      ···
        136
        138
         	)

      
        137
        139
         

      
        138
        140
         	e.Equal(http.StatusOK, httpResp.Code)

      
        
        141
        +	e.NotEmpty(mockMailStore[email])

      
        139
        142
         }

      
        140
        143
         

      
        141
        144
         func (e *AppTestSuite) TestAuthV1_ResendVerificationEmail_wrong() {

      ···
        172
        175
         			}))

      
        173
        176
         

      
        174
        177
         		e.Equal(httpResp.Code, t.expectedCode)

      
        175
        
        -

      
        176
        
        -		// TODO: no email should be sent

      
        
        178
        +		e.Empty(mockMailStore[t.email])

      
        177
        179
         	}

      
        178
        180
         }

      
        179
        181
         

      ···
        342
        344
         	userDB := e.getUserByUsername(username)

      
        343
        345
         	e.Equal(userDB.Username, username)

      
        344
        346
         	e.NoError(e.hasher.Compare(userDB.Password, newPassword))

      
        
        347
        +}

      
        
        348
        +

      
        
        349
        +type (

      
        
        350
        +	apiV1AuthResetPasswordRequest struct {

      
        
        351
        +		Email string `json:"email"`

      
        
        352
        +	}

      
        
        353
        +	apiV1AuthSetPasswordRequest struct {

      
        
        354
        +		Password string `json:"password"`

      
        
        355
        +	}

      
        
        356
        +)

      
        
        357
        +

      
        
        358
        +func (e *AppTestSuite) TestAuthV1_ResetPassword() {

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

      
        
        360
        +	uname := e.uuid()

      
        
        361
        +	uid, _ := e.createAndSingIn(email, uname, "password")

      
        
        362
        +

      
        
        363
        +	httpResp := e.httpRequest(

      
        
        364
        +		http.MethodPost,

      
        
        365
        +		"/api/v1/auth/reset-password",

      
        
        366
        +		e.jsonify(apiV1AuthResetPasswordRequest{

      
        
        367
        +			Email: email,

      
        
        368
        +		}),

      
        
        369
        +	)

      
        
        370
        +

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

      
        
        372
        +

      
        
        373
        +	token := e.getResetPasswordTokenByUserID(uid)

      
        
        374
        +	e.Empty(token.UsedAt)

      
        
        375
        +	e.Equal(mockMailStore[email], token.Token)

      
        
        376
        +

      
        
        377
        +	// set new password

      
        
        378
        +	password := e.uuid()

      
        
        379
        +	httpResp = e.httpRequest(

      
        
        380
        +		http.MethodPost,

      
        
        381
        +		"/api/v1/auth/reset-password/"+token.Token,

      
        
        382
        +		e.jsonify(apiV1AuthSetPasswordRequest{

      
        
        383
        +			Password: password,

      
        
        384
        +		}),

      
        
        385
        +	)

      
        
        386
        +

      
        
        387
        +	dbUser := e.getUserByUsername(uname)

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

      
        
        389
        +	e.NoError(e.hasher.Compare(dbUser.Password, password))

      
        
        390
        +

      
        
        391
        +	token = e.getResetPasswordTokenByUserID(uid)

      
        
        392
        +	e.NotEmpty(token.UsedAt)

      
        
        393
        +}

      
        
        394
        +

      
        
        395
        +func (e *AppTestSuite) TestAuthV1_ResetPassword_nonExistentUser() {

      
        
        396
        +	_, _ = e.createAndSingIn(e.uuid()+"@test.comd", e.uuid(), "password")

      
        
        397
        +	httpResp := e.httpRequest(

      
        
        398
        +		http.MethodPost,

      
        
        399
        +		"/api/v1/auth/reset-password",

      
        
        400
        +		e.jsonify(apiV1AuthResetPasswordRequest{

      
        
        401
        +			Email: e.uuid() + "@testing.com",

      
        
        402
        +		}),

      
        
        403
        +	)

      
        
        404
        +

      
        
        405
        +	e.Equal(httpResp.Code, http.StatusBadRequest)

      
        345
        406
         }

      
        346
        407
         

      
        347
        408
         // createAndSingIn creates an activated username, logs them in,

      
M e2e/e2e_test.go
···
        19
        19
         	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        20
        20
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        21
        21
         	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

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

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

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

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

      ···
        103
        104
         

      
        104
        105
         	sessionrepo := sessionrepo.New(e.postgresDB)

      
        105
        106
         	vertokrepo := vertokrepo.New(e.postgresDB)

      
        
        107
        +	pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(e.postgresDB)

      
        106
        108
         

      
        107
        109
         	oauthProvider := newOauthProviderMock()

      
        108
        110
         

      ···
        112
        114
         		userepo,

      
        113
        115
         		sessionrepo,

      
        114
        116
         		vertokrepo,

      
        
        117
        +		pwdtokrepo,

      
        115
        118
         		e.hasher,

      
        116
        119
         		e.jwtTokenizer,

      
        117
        120
         		newMailerMockService(),

      ···
        120
        123
         		oauthProvider,

      
        121
        124
         		cfg.JwtRefreshTokenTTL,

      
        122
        125
         		cfg.VerificationTokenTTL,

      
        
        126
        +		cfg.ResetPasswordTokenTTL,

      
        123
        127
         	)

      
        124
        128
         

      
        125
        129
         	notecache := notecache.New(e.redisDB, cfg.CacheUsersTTL)

      
M e2e/e2e_utils_db_test.go
···
        166
        166
         	e.require.NoError(err)

      
        167
        167
         	return r

      
        168
        168
         }

      
        
        169
        +

      
        
        170
        +func (e *AppTestSuite) getResetPasswordTokenByUserID(u uuid.UUID) userVerificationToken {

      
        
        171
        +	query, args, err := pgq.

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

      
        
        173
        +		From("password_reset_tokens ").

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

      
        
        175
        +		Limit(1).

      
        
        176
        +		SQL()

      
        
        177
        +

      
        
        178
        +	e.require.NoError(err)

      
        
        179
        +	var r userVerificationToken

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

      
        
        181
        +	e.require.NoError(err)

      
        
        182
        +	return r

      
        
        183
        +}

      
M e2e/mailer_mock_test.go
···
        8
        8
         

      
        9
        9
         var _ mailermq.Mailer = (*mailerMockService)(nil)

      
        10
        10
         

      
        
        11
        +var mockMailStore = make(map[string]string)

      
        
        12
        +

      
        11
        13
         type mailerMockService struct{}

      
        12
        14
         

      
        13
        15
         func newMailerMockService() *mailerMockService {

      
        14
        16
         	return &mailerMockService{}

      
        15
        17
         }

      
        16
        18
         

      
        17
        
        -func (m mailerMockService) SendVerificationEmail(

      
        
        19
        +func (m *mailerMockService) SendVerificationEmail(

      
        
        20
        +	_ context.Context,

      
        
        21
        +	i mailermq.SendVerificationEmailRequest,

      
        
        22
        +) error {

      
        
        23
        +	mockMailStore[i.Receiver] = i.Token

      
        
        24
        +	return nil

      
        
        25
        +}

      
        
        26
        +

      
        
        27
        +func (m *mailerMockService) SendPasswordResetEmail(

      
        18
        28
         	_ context.Context,

      
        19
        
        -	_ mailermq.SendVerificationEmailRequest,

      
        
        29
        +	i mailermq.SendPasswordResetEmailRequest,

      
        20
        30
         ) error {

      
        
        31
        +	mockMailStore[i.Receiver] = i.Token

      
        21
        32
         	return nil

      
        22
        33
         }

      
M internal/config/config.go
···
        40
        40
         	GitHubSecret      string

      
        41
        41
         	GitHubRedirectURL string

      
        42
        42
         

      
        43
        
        -	VerificationTokenTTL time.Duration

      
        
        43
        +	VerificationTokenTTL  time.Duration

      
        
        44
        +	ResetPasswordTokenTTL time.Duration

      
        44
        45
         

      
        45
        46
         	MetricsEnabled bool

      
        46
        47
         	MetricsPort    int

      ···
        92
        93
         		GitHubSecret:      getenvOrDefault("GITHUB_SECRET", ""),

      
        93
        94
         		GitHubRedirectURL: getenvOrDefault("GITHUB_REDIRECTURL", ""),

      
        94
        95
         

      
        95
        
        -		VerificationTokenTTL: mustParseDuration(

      
        96
        
        -			getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h"),

      
        97
        
        -		),

      
        
        96
        +		VerificationTokenTTL:  mustParseDuration(getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h")),

      
        
        97
        +		ResetPasswordTokenTTL: mustParseDuration(getenvOrDefault("RESET_PASSWORD_TOKEN_TTL", "1h")),

      
        98
        98
         

      
        99
        99
         		MetricsPort:    mustGetenvOrDefaultInt("METRICS_PORT", 3001),

      
        100
        100
         		MetricsEnabled: getenvOrDefault("METRICS_ENABLED", "true") == "true",

      
M internal/dtos/user.go
···
        22
        22
         	NewPassword     string

      
        23
        23
         }

      
        24
        24
         

      
        
        25
        +type RequestResetPassword struct {

      
        
        26
        +	Email string

      
        
        27
        +}

      
        
        28
        +

      
        
        29
        +type ResetPassword struct {

      
        
        30
        +	Token       string

      
        
        31
        +	NewPassword string

      
        
        32
        +}

      
        
        33
        +

      
        25
        34
         type Tokens struct {

      
        26
        35
         	Access  string

      
        27
        36
         	Refresh string

      
M internal/events/mailermq/mailermq.go
···
        9
        9
         	"github.com/olexsmir/onasty/internal/transport/http/reqid"

      
        10
        10
         )

      
        11
        11
         

      
        
        12
        +const sendTopic = "mailer.send"

      
        
        13
        +

      
        12
        14
         type Mailer interface {

      
        13
        15
         	SendVerificationEmail(ctx context.Context, input SendVerificationEmailRequest) error

      
        
        16
        +	SendPasswordResetEmail(ctx context.Context, input SendPasswordResetEmailRequest) error

      
        14
        17
         }

      
        15
        18
         

      
        16
        19
         type MailerMQ struct {

      ···
        51
        54
         		return err

      
        52
        55
         	}

      
        53
        56
         

      
        54
        
        -	resp, err := m.nc.RequestWithContext(ctx, "mailer.send", req)

      
        
        57
        +	resp, err := m.nc.RequestWithContext(ctx, sendTopic, req)

      
        
        58
        +	if err != nil {

      
        
        59
        +		return err

      
        
        60
        +	}

      
        
        61
        +

      
        
        62
        +	return events.CheckRespForError(resp)

      
        
        63
        +}

      
        
        64
        +

      
        
        65
        +type SendPasswordResetEmailRequest struct {

      
        
        66
        +	Receiver string

      
        
        67
        +	Token    string

      
        
        68
        +}

      
        
        69
        +

      
        
        70
        +func (m MailerMQ) SendPasswordResetEmail(

      
        
        71
        +	ctx context.Context,

      
        
        72
        +	inp SendPasswordResetEmailRequest,

      
        
        73
        +) error {

      
        
        74
        +	req, err := json.Marshal(sendRequest{

      
        
        75
        +		RequestID:    reqid.GetContext(ctx),

      
        
        76
        +		Receiver:     inp.Receiver,

      
        
        77
        +		TemplateName: "reset_password",

      
        
        78
        +		Options: map[string]string{

      
        
        79
        +			"token": inp.Token,

      
        
        80
        +		},

      
        
        81
        +	})

      
        
        82
        +	if err != nil {

      
        
        83
        +		return err

      
        
        84
        +	}

      
        
        85
        +

      
        
        86
        +	resp, err := m.nc.RequestWithContext(ctx, sendTopic, req)

      
        55
        87
         	if err != nil {

      
        56
        88
         		return err

      
        57
        89
         	}

      
A internal/models/tokens.go
···
        
        1
        +package models

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"errors"

      
        
        5
        +	"time"

      
        
        6
        +

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

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +var (

      
        
        11
        +	ErrResetPasswordTokenExpired  = errors.New("reset password token expired")

      
        
        12
        +	ErrResetPasswordTokenNotFound = errors.New("reset password token not found")

      
        
        13
        +)

      
        
        14
        +

      
        
        15
        +type ResetPasswordToken struct {

      
        
        16
        +	UserID    uuid.UUID

      
        
        17
        +	Token     string

      
        
        18
        +	CreatedAt time.Time

      
        
        19
        +	ExpiresAt time.Time

      
        
        20
        +}

      
        
        21
        +

      
        
        22
        +func (p ResetPasswordToken) IsExpired() bool {

      
        
        23
        +	return p.ExpiresAt.Before(time.Now())

      
        
        24
        +}

      
M internal/models/user.go
···
        13
        13
         	ErrUsernameIsAlreadyInUse  = errors.New("user: username is already in use")

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

      
        15
        15
         

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

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

      
        
        16
        +	ErrResetPasswordTokenAlreadyUsed = errors.New("reset password token is already used")

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

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

      
        18
        19
         

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

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

      ···
        40
        41
         		return ErrUserInvalidEmail

      
        41
        42
         	}

      
        42
        43
         

      
        43
        
        -	if len(u.Password) < 6 {

      
        44
        
        -		return ErrUserInvalidPassword

      
        45
        
        -	}

      
        46
        
        -

      
        47
        44
         	if len(u.Username) == 0 {

      
        48
        45
         		return ErrUserInvalidUsername

      
        49
        46
         	}

      
        50
        47
         

      
        
        48
        +	return u.ValidatePassword()

      
        
        49
        +}

      
        
        50
        +

      
        
        51
        +func (u User) ValidatePassword() error {

      
        
        52
        +	if len(u.Password) < 6 {

      
        
        53
        +		return ErrUserInvalidPassword

      
        
        54
        +	}

      
        51
        55
         	return nil

      
        52
        56
         }

      
        53
        57
         

      
M internal/service/usersrv/usersrv.go
···
        13
        13
         	"github.com/olexsmir/onasty/internal/jwtutil"

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

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

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

      
        16
        17
         	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"

      
        17
        18
         	"github.com/olexsmir/onasty/internal/store/psql/userepo"

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

      ···
        26
        27
         	Logout(ctx context.Context, userID uuid.UUID) error

      
        27
        28
         

      
        28
        29
         	ChangePassword(ctx context.Context, userID uuid.UUID, inp dtos.ChangeUserPassword) error

      
        
        30
        +	RequestPasswordReset(ctx context.Context, inp dtos.RequestResetPassword) error

      
        
        31
        +	ResetPassword(ctx context.Context, inp dtos.ResetPassword) error

      
        29
        32
         

      
        30
        33
         	GetOAuthURL(providerName string) (string, error)

      
        31
        34
         	HandleOAuthLogin(ctx context.Context, providerName, code string) (dtos.Tokens, error)

      ···
        45
        48
         	userstore    userepo.UserStorer

      
        46
        49
         	sessionstore sessionrepo.SessionStorer

      
        47
        50
         	vertokrepo   vertokrepo.VerificationTokenStorer

      
        
        51
        +	pwdtokrepo   passwordtokrepo.PasswordResetTokenStorer

      
        
        52
        +	cache        usercache.UserCacheer

      
        
        53
        +

      
        48
        54
         	hasher       hasher.Hasher

      
        49
        55
         	jwtTokenizer jwtutil.JWTTokenizer

      
        50
        56
         	mailermq     mailermq.Mailer

      
        51
        
        -	cache        usercache.UserCacheer

      
        52
        
        -	googleOauth  oauth.Provider

      
        53
        
        -	githubOauth  oauth.Provider

      
        54
        57
         

      
        55
        
        -	refreshTokenTTL      time.Duration

      
        56
        
        -	verificationTokenTTL time.Duration

      
        
        58
        +	googleOauth oauth.Provider

      
        
        59
        +	githubOauth oauth.Provider

      
        
        60
        +

      
        
        61
        +	refreshTokenTTL       time.Duration

      
        
        62
        +	verificationTokenTTL  time.Duration

      
        
        63
        +	resetPasswordTokenTTL time.Duration

      
        57
        64
         }

      
        58
        65
         

      
        59
        66
         func New(

      
        60
        67
         	userstore userepo.UserStorer,

      
        61
        68
         	sessionstore sessionrepo.SessionStorer,

      
        62
        69
         	vertokrepo vertokrepo.VerificationTokenStorer,

      
        
        70
        +	pwdtokrepo passwordtokrepo.PasswordResetTokenStorer,

      
        63
        71
         	hasher hasher.Hasher,

      
        64
        72
         	jwtTokenizer jwtutil.JWTTokenizer,

      
        65
        73
         	mailermq mailermq.Mailer,

      
        66
        74
         	cache usercache.UserCacheer,

      
        67
        75
         	googleOauth, githubOauth oauth.Provider,

      
        68
        
        -	refreshTokenTTL, verificationTokenTTL time.Duration,

      
        
        76
        +	refreshTokenTTL, verificationTokenTTL, resetPasswordTokenTTL time.Duration,

      
        69
        77
         ) *UserSrv {

      
        70
        78
         	return &UserSrv{

      
        71
        
        -		userstore:            userstore,

      
        72
        
        -		sessionstore:         sessionstore,

      
        73
        
        -		vertokrepo:           vertokrepo,

      
        74
        
        -		hasher:               hasher,

      
        75
        
        -		jwtTokenizer:         jwtTokenizer,

      
        76
        
        -		mailermq:             mailermq,

      
        77
        
        -		cache:                cache,

      
        78
        
        -		googleOauth:          googleOauth,

      
        79
        
        -		githubOauth:          githubOauth,

      
        80
        
        -		refreshTokenTTL:      refreshTokenTTL,

      
        81
        
        -		verificationTokenTTL: verificationTokenTTL,

      
        
        79
        +		userstore:             userstore,

      
        
        80
        +		sessionstore:          sessionstore,

      
        
        81
        +		vertokrepo:            vertokrepo,

      
        
        82
        +		pwdtokrepo:            pwdtokrepo,

      
        
        83
        +		cache:                 cache,

      
        
        84
        +		hasher:                hasher,

      
        
        85
        +		jwtTokenizer:          jwtTokenizer,

      
        
        86
        +		mailermq:              mailermq,

      
        
        87
        +		googleOauth:           googleOauth,

      
        
        88
        +		githubOauth:           githubOauth,

      
        
        89
        +		refreshTokenTTL:       refreshTokenTTL,

      
        
        90
        +		verificationTokenTTL:  verificationTokenTTL,

      
        
        91
        +		resetPasswordTokenTTL: resetPasswordTokenTTL,

      
        82
        92
         	}

      
        83
        93
         }

      
        84
        94
         

      ···
        180
        190
         ) error {

      
        181
        191
         	// TODO: compare current password with providede, and assert on mismatch

      
        182
        192
         

      
        
        193
        +	//nolint:exhaustruct

      
        
        194
        +	if err := (models.User{Password: inp.NewPassword}).ValidatePassword(); err != nil {

      
        
        195
        +		return err

      
        
        196
        +	}

      
        
        197
        +

      
        183
        198
         	oldPass, err := u.hasher.Hash(inp.CurrentPassword)

      
        184
        199
         	if err != nil {

      
        185
        200
         		return err

      ···
        195
        210
         	}

      
        196
        211
         

      
        197
        212
         	return nil

      
        
        213
        +}

      
        
        214
        +

      
        
        215
        +func (u *UserSrv) RequestPasswordReset(ctx context.Context, inp dtos.RequestResetPassword) error {

      
        
        216
        +	user, err := u.userstore.GetByEmail(ctx, inp.Email)

      
        
        217
        +	if err != nil {

      
        
        218
        +		return err

      
        
        219
        +	}

      
        
        220
        +

      
        
        221
        +	token := uuid.Must(uuid.NewV4()).String()

      
        
        222
        +	if err := u.pwdtokrepo.Create(ctx, models.ResetPasswordToken{

      
        
        223
        +		UserID:    user.ID,

      
        
        224
        +		Token:     token,

      
        
        225
        +		CreatedAt: time.Now(),

      
        
        226
        +		ExpiresAt: time.Now().Add(u.resetPasswordTokenTTL),

      
        
        227
        +	}); err != nil {

      
        
        228
        +		return err

      
        
        229
        +	}

      
        
        230
        +

      
        
        231
        +	if err := u.mailermq.SendPasswordResetEmail(ctx, mailermq.SendPasswordResetEmailRequest{

      
        
        232
        +		Receiver: inp.Email,

      
        
        233
        +		Token:    token,

      
        
        234
        +	}); err != nil {

      
        
        235
        +		return err

      
        
        236
        +	}

      
        
        237
        +

      
        
        238
        +	return nil

      
        
        239
        +}

      
        
        240
        +

      
        
        241
        +func (u *UserSrv) ResetPassword(ctx context.Context, inp dtos.ResetPassword) error {

      
        
        242
        +	//nolint:exhaustruct

      
        
        243
        +	if err := (models.User{Password: inp.NewPassword}).ValidatePassword(); err != nil {

      
        
        244
        +		return err

      
        
        245
        +	}

      
        
        246
        +

      
        
        247
        +	uid, err := u.pwdtokrepo.GetUserIDByTokenAndMarkAsUsed(ctx, inp.Token, time.Now())

      
        
        248
        +	if err != nil {

      
        
        249
        +		return err

      
        
        250
        +	}

      
        
        251
        +

      
        
        252
        +	hashedPassword, err := u.hasher.Hash(inp.NewPassword)

      
        
        253
        +	if err != nil {

      
        
        254
        +		return err

      
        
        255
        +	}

      
        
        256
        +

      
        
        257
        +	return u.userstore.SetPassword(ctx, uid, hashedPassword)

      
        198
        258
         }

      
        199
        259
         

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

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"errors"

      
        
        6
        +	"time"

      
        
        7
        +

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

      
        
        9
        +	"github.com/henvic/pgq"

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

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

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

      
        
        13
        +)

      
        
        14
        +

      
        
        15
        +type PasswordResetTokenStorer interface {

      
        
        16
        +	Create(ctx context.Context, input models.ResetPasswordToken) error

      
        
        17
        +

      
        
        18
        +	GetUserIDByTokenAndMarkAsUsed(

      
        
        19
        +		ctx context.Context,

      
        
        20
        +		token string,

      
        
        21
        +		usedAT time.Time,

      
        
        22
        +	) (uuid.UUID, error)

      
        
        23
        +}

      
        
        24
        +

      
        
        25
        +var _ PasswordResetTokenStorer = (*PasswordResetTokenRepo)(nil)

      
        
        26
        +

      
        
        27
        +type PasswordResetTokenRepo struct {

      
        
        28
        +	db *psqlutil.DB

      
        
        29
        +}

      
        
        30
        +

      
        
        31
        +func NewPasswordResetTokenRepo(db *psqlutil.DB) *PasswordResetTokenRepo {

      
        
        32
        +	return &PasswordResetTokenRepo{

      
        
        33
        +		db: db,

      
        
        34
        +	}

      
        
        35
        +}

      
        
        36
        +

      
        
        37
        +func (r *PasswordResetTokenRepo) Create(ctx context.Context, token models.ResetPasswordToken,

      
        
        38
        +) error {

      
        
        39
        +	query, aggs, err := pgq.

      
        
        40
        +		Insert("password_reset_tokens").

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

      
        
        42
        +		Values(token.UserID, token.Token, token.CreatedAt, token.ExpiresAt).

      
        
        43
        +		SQL()

      
        
        44
        +	if err != nil {

      
        
        45
        +		return err

      
        
        46
        +	}

      
        
        47
        +

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

      
        
        49
        +	return err

      
        
        50
        +}

      
        
        51
        +

      
        
        52
        +func (r *PasswordResetTokenRepo) GetUserIDByTokenAndMarkAsUsed(

      
        
        53
        +	ctx context.Context,

      
        
        54
        +	token string,

      
        
        55
        +	usedAt time.Time,

      
        
        56
        +) (uuid.UUID, error) {

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

      
        
        58
        +	if err != nil {

      
        
        59
        +		return uuid.Nil, err

      
        
        60
        +	}

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

      
        
        62
        +

      
        
        63
        +	var isUsed bool

      
        
        64
        +	var expiresAt time.Time

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

      
        
        66
        +		Scan(&isUsed, &expiresAt)

      
        
        67
        +	if err != nil {

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

      
        
        69
        +			return uuid.Nil, models.ErrResetPasswordTokenNotFound

      
        
        70
        +		}

      
        
        71
        +		return uuid.Nil, err

      
        
        72
        +	}

      
        
        73
        +

      
        
        74
        +	if isUsed {

      
        
        75
        +		return uuid.Nil, models.ErrResetPasswordTokenAlreadyUsed

      
        
        76
        +	}

      
        
        77
        +

      
        
        78
        +	if time.Now().After(expiresAt) {

      
        
        79
        +		return uuid.Nil, models.ErrResetPasswordTokenExpired

      
        
        80
        +	}

      
        
        81
        +

      
        
        82
        +	query := `--sql

      
        
        83
        +update password_reset_tokens

      
        
        84
        +set used_at = $1

      
        
        85
        +where token = $2

      
        
        86
        +returning user_id`

      
        
        87
        +

      
        
        88
        +	var userID uuid.UUID

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

      
        
        90
        +	if err != nil {

      
        
        91
        +		return uuid.Nil, err

      
        
        92
        +	}

      
        
        93
        +

      
        
        94
        +	return userID, tx.Commit(ctx)

      
        
        95
        +}

      
M internal/transport/http/apiv1/apiv1.go
···
        30
        30
         		auth.POST("/refresh-tokens", a.refreshTokensHandler)

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

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

      
        
        33
        +		auth.POST("/reset-password", a.requestResetPasswordHandler)

      
        
        34
        +		auth.POST("/reset-password/:token", a.resetPasswordHandler)

      
        33
        35
         

      
        34
        36
         		authorized := auth.Group("/", a.authorizedMiddleware)

      
        35
        37
         		{

      
M internal/transport/http/apiv1/auth.go
···
        119
        119
         	c.Status(http.StatusOK)

      
        120
        120
         }

      
        121
        121
         

      
        
        122
        +type requestResetPasswordRequest struct {

      
        
        123
        +	Email string `json:"email"`

      
        
        124
        +}

      
        
        125
        +

      
        
        126
        +func (a *APIV1) requestResetPasswordHandler(c *gin.Context) {

      
        
        127
        +	var req requestResetPasswordRequest

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

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

      
        
        130
        +		return

      
        
        131
        +	}

      
        
        132
        +

      
        
        133
        +	if err := a.usersrv.RequestPasswordReset(c.Request.Context(), dtos.RequestResetPassword{

      
        
        134
        +		Email: req.Email,

      
        
        135
        +	}); err != nil {

      
        
        136
        +		errorResponse(c, err)

      
        
        137
        +		return

      
        
        138
        +	}

      
        
        139
        +

      
        
        140
        +	c.Status(http.StatusOK)

      
        
        141
        +}

      
        
        142
        +

      
        
        143
        +type resetPasswordRequest struct {

      
        
        144
        +	Password string `json:"password"`

      
        
        145
        +}

      
        
        146
        +

      
        
        147
        +func (a *APIV1) resetPasswordHandler(c *gin.Context) {

      
        
        148
        +	var req resetPasswordRequest

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

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

      
        
        151
        +		return

      
        
        152
        +	}

      
        
        153
        +

      
        
        154
        +	if err := a.usersrv.ResetPassword(

      
        
        155
        +		c.Request.Context(),

      
        
        156
        +		dtos.ResetPassword{

      
        
        157
        +			Token:       c.Param("token"),

      
        
        158
        +			NewPassword: req.Password,

      
        
        159
        +		},

      
        
        160
        +	); err != nil {

      
        
        161
        +		errorResponse(c, err)

      
        
        162
        +		return

      
        
        163
        +	}

      
        
        164
        +

      
        
        165
        +	c.Status(http.StatusOK)

      
        
        166
        +}

      
        
        167
        +

      
        122
        168
         func (a *APIV1) logOutHandler(c *gin.Context) {

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

      
        124
        170
         		errorResponse(c, err)

      
M internal/transport/http/apiv1/response.go
···
        18
        18
         

      
        19
        19
         func errorResponse(c *gin.Context, err error) {

      
        20
        20
         	if errors.Is(err, usersrv.ErrProviderNotSupported) ||

      
        
        21
        +		errors.Is(err, models.ErrResetPasswordTokenAlreadyUsed) ||

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

      
        21
        23
         		errors.Is(err, models.ErrUserEmailIsAlreadyInUse) ||

      
        22
        24
         		errors.Is(err, models.ErrUsernameIsAlreadyInUse) ||

      
        23
        25
         		errors.Is(err, models.ErrUserIsAlreadyVerified) ||

      
M mailer/README.md
···
        37
        37
         #### Template specific options

      
        38
        38
         - `email_verification`

      
        39
        39
           - `token` the token that is used in verification link

      
        40
        
        -

      
        41
        
        -

      
        
        40
        +- `reset_password`

      
        
        41
        +  - `token` the token that is used in password reset link

      
M mailer/service.go
···
        33
        33
         	go func() {

      
        34
        34
         		select {

      
        35
        35
         		case <-ctx.Done():

      
        36
        
        -			slog.ErrorContext(ctx, "failed to send verification email", "err", ctx.Err())

      
        
        36
        +			slog.ErrorContext(ctx, "failed to send email",

      
        
        37
        +				"template_name", templateName,

      
        
        38
        +				"err", ctx.Err())

      
        37
        39
         			return

      
        38
        40
         		default:

      
        39
        41
         			if err := s.mg.Send(ctx, receiver, t.Subject, t.Body); err != nil {

      
        40
        
        -				slog.ErrorContext(ctx, "failed to send verification email", "err", err)

      
        
        42
        +				slog.ErrorContext(ctx, "failed to send email",

      
        
        43
        +					"template_name", templateName,

      
        
        44
        +					"err", err)

      
        41
        45
         			}

      
        42
        46
         			cancel()

      
        43
        47
         		}

      
M mailer/template.go
···
        13
        13
         type TemplateFunc func(args map[string]string) Template

      
        14
        14
         

      
        15
        15
         func getTemplate(appURL string, templateName string) (TemplateFunc, error) {

      
        16
        
        -	if templateName == "email_verification" {

      
        
        16
        +	switch templateName {

      
        
        17
        +	case "email_verification":

      
        17
        18
         		return emailVerificationTemplate(appURL), nil

      
        
        19
        +	case "reset_password":

      
        
        20
        +		return passwordResetTemplate(appURL), nil

      
        
        21
        +	default:

      
        
        22
        +		return nil, errors.New("failed to get template") //nolint:err113

      
        18
        23
         	}

      
        19
        
        -

      
        20
        
        -	return nil, errors.New("failed to get template") //nolint:err113

      
        21
        24
         }

      
        22
        25
         

      
        23
        26
         func emailVerificationTemplate(appURL string) TemplateFunc {

      ···
        32
        35
         		}

      
        33
        36
         	}

      
        34
        37
         }

      
        
        38
        +

      
        
        39
        +func passwordResetTemplate(appURL string) TemplateFunc {

      
        
        40
        +	return func(opts map[string]string) Template {

      
        
        41
        +		return Template{

      
        
        42
        +			Subject: "Onasty: reset your password",

      
        
        43
        +			// TODO: when ui is ready, change the link to the ui

      
        
        44
        +			Body: fmt.Sprintf(`To reset your password, use this api:

      
        
        45
        +<a href="%[1]s/api/v1/auth/reset-password/%[2]s">%[1]s/api/v1/auth/reset-password/%[2]s</a>

      
        
        46
        +<br />

      
        
        47
        +<br />

      
        
        48
        +This link will expire after an hour.`, appURL, opts["token"]),

      
        
        49
        +		}

      
        
        50
        +	}

      
        
        51
        +}

      
A migrations/20250509131258_password_reset_tokens.down.sql
···
        
        1
        +DROP TABLE password_reset_tokens;

      
A migrations/20250509131258_password_reset_tokens.up.sql
···
        
        1
        +CREATE TABLE password_reset_tokens (

      
        
        2
        +    id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (),

      
        
        3
        +    user_id uuid NOT NULL 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
        +);