28 files changed,
969 insertions(+),
132 deletions(-)
Author:
Smirnov Oleksandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2024-09-09 13:02:26 +0300
Parent:
e0dc5bb
jump to
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
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/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/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/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.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 +);