33 files changed,
1313 insertions(+),
68 deletions(-)
Author:
Smirnov Oleksandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2024-07-17 02:23:56 +0300
Parent:
a667923
jump to
M
.env.example
··· 1 1 APP_ENV="debug" 2 2 SERVER_PORT=3000 3 +PASSWORD_SALT="onasty" 3 4 4 5 LOG_LEVEL="debug" 5 6 LOG_FORMAT="text" 7 + 8 +JWT_SIGNING_KEY="supersecret" 9 +JWT_ACCESS_TOKEN_TTL="30m" 10 +JWT_REFRESH_TOKEN_TTL="15d" 6 11 7 12 POSTGRES_USERNAME="onasty" 8 13 POSTGRES_PASSWORD="qwerty"
M
.github/workflows/golang.yml
··· 17 17 go-version-file: go.mod 18 18 cache-dependency-path: go.mod 19 19 20 - - name: Golangci Lint 21 - uses: golangci/golangci-lint-action@v3 22 - with: 23 - version: latest 24 - args: ./... 25 - 26 20 - name: Build API 27 21 run: go build -o .bin/onasty ./cmd/server/ 28 22
A
.github/workflows/linter.yml
··· 1 +name: linter 2 + 3 +on: 4 + push: 5 + pull_request: 6 + 7 +jobs: 8 + golang: 9 + runs-on: ubuntu-latest 10 + steps: 11 + - uses: actions/checkout@v3 12 + 13 + - name: Golangci Lint 14 + uses: golangci/golangci-lint-action@v3 15 + with: 16 + version: latest 17 + args: ./...
M
cmd/server/main.go
··· 11 11 12 12 "github.com/gin-gonic/gin" 13 13 "github.com/olexsmir/onasty/internal/config" 14 + "github.com/olexsmir/onasty/internal/hasher" 15 + "github.com/olexsmir/onasty/internal/jwtutil" 14 16 "github.com/olexsmir/onasty/internal/service/usersrv" 17 + "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" 15 18 "github.com/olexsmir/onasty/internal/store/psql/userepo" 16 19 "github.com/olexsmir/onasty/internal/store/psqlutil" 17 20 httptransport "github.com/olexsmir/onasty/internal/transport/http" ··· 44 47 } 45 48 46 49 // app deps 50 + sha256Hasher := hasher.NewSHA256Hasher(cfg.PasswordSalt) 51 + jwtTokenizer := jwtutil.NewJWTUtil(cfg.JwtSigningKey, cfg.JwtAccessTokenTTL) 52 + 53 + sessionrepo := sessionrepo.New(psqlDB) 54 + 47 55 userepo := userepo.New(psqlDB) 48 - usersrv := usersrv.New(userepo) 56 + usersrv := usersrv.New(userepo, sessionrepo, sha256Hasher, jwtTokenizer) 49 57 50 58 handler := httptransport.NewTransport(usersrv) 51 59
A
e2e/apiv1_auth_test.go
··· 1 +package e2e 2 + 3 +import ( 4 + "net/http" 5 + 6 + "github.com/gofrs/uuid/v5" 7 +) 8 + 9 +type apiv1AuthSignUpRequest struct { 10 + Username string `json:"username"` 11 + Email string `json:"email"` 12 + Password string `json:"password"` 13 +} 14 + 15 +func (e *AppTestSuite) TestAuthV1_SignUP() { 16 + username := "test" + e.uuid() 17 + email := e.uuid() + "test@test.com" 18 + password := "password" 19 + 20 + httpResp := e.httpRequest( 21 + http.MethodPost, 22 + "/api/v1/auth/signup", 23 + e.jsonify(apiv1AuthSignUpRequest{ 24 + Username: username, 25 + Email: email, 26 + Password: password, 27 + }), 28 + ) 29 + 30 + dbUser := e.getUserFromDBByUsername(username) 31 + hashedPasswd, err := e.hasher.Hash(password) 32 + e.require.NoError(err) 33 + 34 + e.Equal(http.StatusCreated, httpResp.Code) 35 + e.Equal(dbUser.Email, email) 36 + e.Equal(dbUser.Password, hashedPasswd) 37 +} 38 + 39 +func (e *AppTestSuite) TestAuthV1_SignUP_badrequest() { 40 + tests := []struct { 41 + name string 42 + username string 43 + email string 44 + password string 45 + }{ 46 + {name: "all fiels empty", email: "", password: "", username: ""}, 47 + { 48 + name: "non valid email", 49 + email: "email", 50 + password: "password", 51 + }, 52 + { 53 + name: "non valid password", 54 + email: "test@test.com", 55 + password: "12345", 56 + username: "test", 57 + }, 58 + } 59 + for _, t := range tests { 60 + httpResp := e.httpRequest( 61 + http.MethodPost, 62 + "/api/v1/auth/signup", 63 + e.jsonify(apiv1AuthSignUpRequest{ 64 + Username: t.username, 65 + Email: t.email, 66 + Password: t.password, 67 + }), 68 + ) 69 + 70 + e.Equal(http.StatusBadRequest, httpResp.Code) 71 + } 72 +} 73 + 74 +type apiv1AuthSignInRequest struct { 75 + Email string `json:"email"` 76 + Password string `json:"password"` 77 +} 78 + 79 +type apiv1AuthSignInResponse struct { 80 + AccessToken string `json:"access_token"` 81 + RefreshToken string `json:"refresh_token"` 82 +} 83 + 84 +func (e *AppTestSuite) TestAuthV1_SignIn() { 85 + email := e.uuid() + "email@email.com" 86 + password := "qwerty" 87 + 88 + uid := e.insertUserIntoDB("test", email, password) 89 + 90 + httpResp := e.httpRequest( 91 + http.MethodPost, 92 + "/api/v1/auth/signin", 93 + e.jsonify(apiv1AuthSignInRequest{ 94 + Email: email, 95 + Password: password, 96 + }), 97 + ) 98 + 99 + var body apiv1AuthSignInResponse 100 + e.readBodyAndUnjsonify(httpResp.Body, &body) 101 + 102 + session := e.getLastUserSessionByUserID(uid) 103 + parsedToken := e.parseJwtToken(body.AccessToken) 104 + 105 + e.Equal(http.StatusOK, httpResp.Code) 106 + e.Equal(body.RefreshToken, session.RefreshToken) 107 + e.Equal(parsedToken.UserID, uid.String()) 108 +} 109 + 110 +func (e *AppTestSuite) TestAuthV1_SignIn_wrong() { 111 + password := "password" 112 + email := e.uuid() + "@test.com" 113 + e.insertUserIntoDB(e.uuid(), email, "password") 114 + 115 + tests := []struct { 116 + name string 117 + email string 118 + password string 119 + }{ 120 + { 121 + name: "wrong email", 122 + email: "wrong@emai.com", 123 + password: password, 124 + }, 125 + { 126 + name: "wrong password", 127 + email: email, 128 + password: "wrong-wrong", 129 + }, 130 + } 131 + 132 + for _, t := range tests { 133 + httpResp := e.httpRequest( 134 + http.MethodPost, 135 + "/api/v1/auth/signin", 136 + e.jsonify(apiv1AuthSignInRequest{ 137 + Email: t.email, 138 + Password: t.password, 139 + }), 140 + ) 141 + 142 + e.Equal(http.StatusUnauthorized, httpResp.Code) 143 + } 144 +} 145 + 146 +type apiv1AuthRefreshTokensRequest struct { 147 + RefreshToken string `json:"refresh_token"` 148 +} 149 + 150 +func (e *AppTestSuite) TestAuthV1_RefreshTokens() { 151 + uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password") 152 + httpResp := e.httpRequest( 153 + http.MethodPost, 154 + "/api/v1/auth/refresh-tokens", 155 + e.jsonify(apiv1AuthRefreshTokensRequest{ 156 + RefreshToken: toks.RefreshToken, 157 + }), 158 + ) 159 + 160 + var body apiv1AuthSignInResponse 161 + e.readBodyAndUnjsonify(httpResp.Body, &body) 162 + 163 + session := e.getLastUserSessionByUserID(uid) 164 + parsedToken := e.parseJwtToken(body.AccessToken) 165 + e.Equal(parsedToken.UserID, uid.String()) 166 + 167 + e.Equal(httpResp.Code, http.StatusOK) 168 + e.NotEqual(toks.RefreshToken, body.RefreshToken) 169 + e.Equal(body.RefreshToken, session.RefreshToken) 170 +} 171 + 172 +func (e *AppTestSuite) TestAuthV1_RefreshTokens_wrong() { 173 + httpResp := e.httpRequest( 174 + http.MethodPost, 175 + "/api/v1/auth/refresh-tokens", 176 + e.jsonify(apiv1AuthRefreshTokensRequest{ 177 + RefreshToken: e.uuid(), 178 + }), 179 + ) 180 + 181 + e.Equal(httpResp.Code, http.StatusBadRequest) 182 +} 183 + 184 +func (e *AppTestSuite) TestAuthV1_Logout() { 185 + uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password") 186 + 187 + session := e.getLastUserSessionByUserID(uid) 188 + e.NotEmpty(session.RefreshToken) 189 + 190 + httpResp := e.httpRequest(http.MethodPost, "/api/v1/auth/logout", nil, toks.AccessToken) 191 + 192 + e.Equal(httpResp.Code, http.StatusNoContent) 193 + 194 + session = e.getLastUserSessionByUserID(uid) 195 + e.Empty(session.RefreshToken) 196 +} 197 + 198 +func (e *AppTestSuite) createAndSingIn( 199 + email, username, password string, 200 +) (uuid.UUID, apiv1AuthSignInResponse) { 201 + uid := e.insertUserIntoDB(username, email, password) 202 + httpResp := e.httpRequest( 203 + http.MethodPost, 204 + "/api/v1/auth/signin", 205 + e.jsonify(apiv1AuthSignInRequest{ 206 + Email: email, 207 + Password: password, 208 + }), 209 + ) 210 + 211 + e.Equal(httpResp.Code, http.StatusOK) 212 + 213 + var body apiv1AuthSignInResponse 214 + e.readBodyAndUnjsonify(httpResp.Body, &body) 215 + 216 + return uid, body 217 +}
M
e2e/e2e_test.go
··· 5 5 "fmt" 6 6 "net/http" 7 7 "testing" 8 + "time" 8 9 9 10 "github.com/gin-gonic/gin" 10 11 "github.com/golang-migrate/migrate/v4" 11 12 "github.com/golang-migrate/migrate/v4/database/pgx" 12 13 "github.com/jackc/pgx/v5/stdlib" 14 + "github.com/olexsmir/onasty/internal/hasher" 15 + "github.com/olexsmir/onasty/internal/jwtutil" 13 16 "github.com/olexsmir/onasty/internal/service/usersrv" 17 + "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" 14 18 "github.com/olexsmir/onasty/internal/store/psql/userepo" 15 19 "github.com/olexsmir/onasty/internal/store/psqlutil" 16 20 httptransport "github.com/olexsmir/onasty/internal/transport/http" ··· 34 38 postgresDB *psqlutil.DB 35 39 stopPostgres stopDBFunc 36 40 37 - router http.Handler 41 + router http.Handler 42 + hasher hasher.Hasher 43 + jwtTokenizer jwtutil.JWTTokenizer 38 44 } 39 45 ) 40 46 ··· 49 55 suite.Run(t, new(AppTestSuite)) 50 56 } 51 57 52 -func (s *AppTestSuite) SetupSuite() { 53 - s.ctx = context.Background() 54 - s.require = s.Require() 58 +func (e *AppTestSuite) SetupSuite() { 59 + e.ctx = context.Background() 60 + e.require = e.Require() 55 61 56 - db, stop, err := s.prepPostgres() 57 - s.Require().NoError(err) 62 + db, stop, err := e.prepPostgres() 63 + e.Require().NoError(err) 58 64 59 - s.postgresDB = db 60 - s.stopPostgres = stop 65 + e.postgresDB = db 66 + e.stopPostgres = stop 61 67 62 - s.initDeps() 68 + e.initDeps() 63 69 } 64 70 65 -func (s *AppTestSuite) TearDownSuite() { 66 - s.stopPostgres() 71 +func (e *AppTestSuite) TearDownSuite() { 72 + e.stopPostgres() 67 73 } 68 74 69 75 // initDeps initializes the dependencies for the app 70 76 // and sets up the router for tests 71 -func (s *AppTestSuite) initDeps() { 72 - userepo := userepo.New(s.postgresDB) 73 - usersrv := usersrv.New(userepo) 77 +func (e *AppTestSuite) initDeps() { 78 + e.hasher = hasher.NewSHA256Hasher("pass_salt") 79 + e.jwtTokenizer = jwtutil.NewJWTUtil("jwt", time.Hour) 80 + 81 + sessionrepo := sessionrepo.New(e.postgresDB) 82 + 83 + userepo := userepo.New(e.postgresDB) 84 + usersrv := usersrv.New(userepo, sessionrepo, e.hasher, e.jwtTokenizer) 74 85 75 86 handler := httptransport.NewTransport(usersrv) 76 - s.router = handler.Handler() 87 + e.router = handler.Handler() 77 88 } 78 89 79 -func (s *AppTestSuite) prepPostgres() (*psqlutil.DB, stopDBFunc, error) { 90 +func (e *AppTestSuite) prepPostgres() (*psqlutil.DB, stopDBFunc, error) { 80 91 dbCredential := "testing" 81 92 postgresContainer, err := postgres.RunContainer( 82 - s.ctx, 93 + e.ctx, 83 94 testcontainers.WithImage("postgres:16-alpine"), 84 95 postgres.WithUsername(dbCredential), 85 96 postgres.WithPassword(dbCredential), ··· 87 98 testcontainers.WithWaitStrategy( 88 99 wait.ForListeningPort("5432/tcp")), 89 100 ) 90 - s.require.NoError(err) 101 + e.require.NoError(err) 91 102 92 103 stop := func() { 93 - err = postgresContainer.Terminate(s.ctx) 94 - s.require.NoError(err) 104 + err = postgresContainer.Terminate(e.ctx) 105 + e.require.NoError(err) 95 106 } 96 107 97 108 // connect to the db 98 - host, err := postgresContainer.Host(s.ctx) 99 - s.require.NoError(err) 109 + host, err := postgresContainer.Host(e.ctx) 110 + e.require.NoError(err) 100 111 101 - port, err := postgresContainer.MappedPort(s.ctx, "5432/tcp") 102 - s.require.NoError(err) 112 + port, err := postgresContainer.MappedPort(e.ctx, "5432/tcp") 113 + e.require.NoError(err) 103 114 104 115 db, err := psqlutil.Connect( 105 - s.ctx, 116 + e.ctx, 106 117 fmt.Sprintf( //nolint:nosprintfhostport 107 118 "postgres://%s:%s@%s:%s/%s", 108 119 dbCredential, 109 120 dbCredential, 110 121 host, 111 - port, 122 + port.Port(), 112 123 dbCredential, 113 124 ), 114 125 ) 115 - s.require.NoError(err) 126 + e.require.NoError(err) 116 127 117 128 // run migrations 118 129 sdb := stdlib.OpenDBFromPool(db.Pool) 119 130 driver, err := pgx.WithInstance(sdb, &pgx.Config{}) 120 - s.require.NoError(err) 131 + e.require.NoError(err) 121 132 122 133 m, err := migrate.NewWithDatabaseInstance( 123 134 "file://../migrations/", 124 135 "pgxv5", driver, 125 136 ) 126 - s.require.NoError(err) 137 + e.require.NoError(err) 127 138 128 139 err = m.Up() 129 - s.require.NoError(err) 140 + e.require.NoError(err) 130 141 131 142 return db, stop, driver.Close() 132 143 }
A
e2e/e2e_utils_db_test.go
··· 1 +package e2e 2 + 3 +import ( 4 + "errors" 5 + "time" 6 + 7 + "github.com/gofrs/uuid/v5" 8 + "github.com/henvic/pgq" 9 + "github.com/jackc/pgx/v5" 10 + "github.com/olexsmir/onasty/internal/models" 11 +) 12 + 13 +func (e *AppTestSuite) getUserFromDBByUsername(username string) models.User { 14 + query, args, err := pgq. 15 + Select("id", "username", "email", "password", "created_at", "last_login_at"). 16 + From("users"). 17 + Where(pgq.Eq{ 18 + "username": username, 19 + }). 20 + SQL() 21 + e.require.NoError(err) 22 + 23 + var user models.User 24 + err = e.postgresDB.QueryRow(e.ctx, query, args...). 25 + Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.LastLoginAt) 26 + e.require.NoError(err) 27 + 28 + return user 29 +} 30 + 31 +func (e *AppTestSuite) insertUserIntoDB(uname, email, passwd string) uuid.UUID { 32 + p, err := e.hasher.Hash(passwd) 33 + e.require.NoError(err) 34 + 35 + query, args, err := pgq. 36 + Insert("users"). 37 + Columns("username", "email", "password", "activated", "created_at", "last_login_at"). 38 + Values(uname, email, p, true, time.Now(), time.Now()). 39 + Returning("id"). 40 + SQL() 41 + e.require.NoError(err) 42 + 43 + var id uuid.UUID 44 + err = e.postgresDB.QueryRow(e.ctx, query, args...).Scan(&id) 45 + e.require.NoError(err) 46 + 47 + return id 48 +} 49 + 50 +func (e *AppTestSuite) getLastUserSessionByUserID(uid uuid.UUID) models.Session { 51 + query, args, err := pgq. 52 + Select("refresh_token", "expires_at"). 53 + From("sessions"). 54 + Where(pgq.Eq{"user_id": uid.String()}). 55 + OrderBy("expires_at DESC"). 56 + SQL() 57 + e.require.NoError(err) 58 + 59 + var session models.Session 60 + err = e.postgresDB.QueryRow(e.ctx, query, args...). 61 + Scan(&session.RefreshToken, &session.ExpiresAt) 62 + if errors.Is(pgx.ErrNoRows, err) { 63 + return models.Session{} 64 + } 65 + 66 + e.require.NoError(err) 67 + return session 68 +}
A
e2e/e2e_utils_test.go
··· 1 +package e2e 2 + 3 +import ( 4 + "bytes" 5 + "encoding/json" 6 + "io" 7 + "net/http" 8 + "net/http/httptest" 9 + 10 + "github.com/gofrs/uuid/v5" 11 + "github.com/olexsmir/onasty/internal/jwtutil" 12 +) 13 + 14 +// jsonify marshalls v into json and returns it as []byte 15 +func (e *AppTestSuite) jsonify(v any) []byte { 16 + r, err := json.Marshal(v) 17 + e.require.NoError(err) 18 + return r 19 +} 20 + 21 +// readBodyAndUnjsonify reads body of `httptest.ResponseRecorder` and unmarshalls it into res 22 +// 23 +// Example: 24 +// 25 +// var res struct { message string `json:"message"` } 26 +// readBodyAndUnjsonify(httpResp.Body, &res) 27 +func (e *AppTestSuite) readBodyAndUnjsonify(b *bytes.Buffer, res any) { 28 + respData, err := io.ReadAll(b) 29 + e.require.NoError(err) 30 + 31 + err = json.Unmarshal(respData, &res) 32 + e.require.NoError(err) 33 +} 34 + 35 +// httpRequest sends http request to the server and returns `httptest.ResponseRecorder` 36 +// conteny-type always set to application/json 37 +func (e *AppTestSuite) httpRequest( 38 + method, url string, //nolint:unparam // TODO: fix me later 39 + body []byte, 40 + accessToken ...string, 41 +) *httptest.ResponseRecorder { 42 + req, err := http.NewRequest(method, url, bytes.NewBuffer(body)) 43 + e.require.NoError(err) 44 + 45 + req.Header.Set("Content-type", "application/json") 46 + 47 + if len(accessToken) == 1 { 48 + req.Header.Set("Authorization", "Bearer "+accessToken[0]) 49 + } 50 + 51 + resp := httptest.NewRecorder() 52 + e.router.ServeHTTP(resp, req) 53 + 54 + return resp 55 +} 56 + 57 +// uuid generates a new UUID and returns it as a string 58 +func (e *AppTestSuite) uuid() string { 59 + u, err := uuid.NewV4() 60 + e.require.NoError(err) 61 + return u.String() 62 +} 63 + 64 +// parseJwtToken util func that parses jwt token and returns payload 65 +func (e *AppTestSuite) parseJwtToken(t string) jwtutil.Payload { 66 + r, err := e.jwtTokenizer.Parse(t) 67 + e.require.NoError(err) 68 + return r 69 +}
M
go.mod
··· 5 5 require ( 6 6 github.com/gin-gonic/gin v1.10.0 7 7 github.com/gofrs/uuid/v5 v5.0.0 8 + github.com/golang-jwt/jwt/v5 v5.2.1 8 9 github.com/golang-migrate/migrate/v4 v4.17.1 10 + github.com/henvic/pgq v0.0.2 11 + github.com/jackc/pgconn v1.14.3 9 12 github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 10 13 github.com/jackc/pgx/v5 v5.6.0 11 14 github.com/stretchr/testify v1.9.0 ··· 47 50 github.com/hashicorp/errwrap v1.1.0 // indirect 48 51 github.com/hashicorp/go-multierror v1.1.1 // indirect 49 52 github.com/jackc/chunkreader/v2 v2.0.1 // indirect 50 - github.com/jackc/pgconn v1.14.3 // indirect 51 53 github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect 52 54 github.com/jackc/pgio v1.0.0 // indirect 53 55 github.com/jackc/pgpassfile v1.0.0 // indirect
M
go.sum
··· 81 81 github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= 82 82 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 83 83 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 84 +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 85 +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 84 86 github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= 85 87 github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= 86 88 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= ··· 100 102 github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 101 103 github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 102 104 github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 105 +github.com/henvic/pgq v0.0.2 h1:4q/G/cW7zpxpwq672Xuh7BkcKcXonZJ6b9kR8ub3EwQ= 106 +github.com/henvic/pgq v0.0.2/go.mod h1:1Q6dKMwtbe2glBXlusJvNZnJrvgbwub/KcfiB/7UXA4= 103 107 github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= 104 108 github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 105 109 github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
M
internal/config/config.go
··· 2 2 3 3 import ( 4 4 "os" 5 + "time" 5 6 ) 6 7 7 8 type Config struct { 8 - AppEnv string 9 - ServerPort string 9 + AppEnv string 10 + ServerPort string 11 + PasswordSalt string 12 + 13 + JwtSigningKey string 14 + JwtAccessTokenTTL time.Duration 15 + JwtRefreshTokenTTL time.Duration 10 16 11 17 LogLevel string 12 18 LogFormat string ··· 16 22 17 23 func NewConfig() *Config { 18 24 return &Config{ 19 - AppEnv: getenvOrDefault("APP_ENV", "debug"), 20 - ServerPort: getenvOrDefault("SERVER_PORT", "3000"), 21 - LogLevel: getenvOrDefault("LOG_LEVEL", "debug"), 22 - LogFormat: getenvOrDefault("LOG_FORMAT", "json"), 23 - PostgresDSN: getenvOrDefault("POSTGRESQL_DSN", ""), 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", ""), 24 34 } 25 35 } 26 36 ··· 34 44 } 35 45 return def 36 46 } 47 + 48 +func mustParseDuration(dur string) time.Duration { 49 + d, _ := time.ParseDuration(dur) 50 + return d 51 +}
A
internal/dtos/user.go
··· 1 +package dtos 2 + 3 +import ( 4 + "time" 5 + 6 + "github.com/gofrs/uuid/v5" 7 +) 8 + 9 +type UserDTO struct { 10 + ID uuid.UUID 11 + Username string 12 + Email string 13 + Password string 14 + CreatedAt time.Time 15 + LastLoginAt time.Time 16 +} 17 + 18 +type CreateUserDTO struct { 19 + Username string 20 + Email string 21 + Password string 22 + CreatedAt time.Time 23 + LastLoginAt time.Time 24 +} 25 + 26 +type SignInDTO struct { 27 + Email string 28 + Password string 29 +}
A
internal/hasher/sha256.go
··· 1 +package hasher 2 + 3 +import ( 4 + "crypto/sha256" 5 + "encoding/hex" 6 +) 7 + 8 +type SHA256Hasher struct { 9 + salt string 10 +} 11 + 12 +func NewSHA256Hasher(salt string) *SHA256Hasher { 13 + return &SHA256Hasher{salt: salt} 14 +} 15 + 16 +func (h *SHA256Hasher) Hash(inp string) (string, error) { 17 + hash := sha256.New() 18 + if _, err := hash.Write([]byte(inp)); err != nil { 19 + return "", err 20 + } 21 + return hex.EncodeToString(hash.Sum([]byte(h.salt))), nil 22 +}
A
internal/jwtutil/jwtutil.go
··· 1 +package jwtutil 2 + 3 +import ( 4 + "crypto/rand" 5 + "encoding/hex" 6 + "errors" 7 + "time" 8 + 9 + "github.com/golang-jwt/jwt/v5" 10 +) 11 + 12 +type JWTTokenizer interface { 13 + // AccessToken generates a new access token with the given payload 14 + AccessToken(pl Payload) (string, error) 15 + 16 + // RefreshToken generates a new refresh token 17 + RefreshToken() (string, error) 18 + 19 + // Parse parses the token and returns the payload 20 + Parse(token string) (Payload, error) 21 +} 22 + 23 +type Payload struct { 24 + UserID string 25 +} 26 + 27 +var _ JWTTokenizer = (*JWTUtil)(nil) 28 + 29 +type JWTUtil struct { 30 + signingKey string 31 + accessTokenTTL time.Duration 32 +} 33 + 34 +func NewJWTUtil(signingKey string, accessTokenTTL time.Duration) *JWTUtil { 35 + return &JWTUtil{ 36 + signingKey: signingKey, 37 + accessTokenTTL: accessTokenTTL, 38 + } 39 +} 40 + 41 +func (j *JWTUtil) AccessToken(pl Payload) (string, error) { 42 + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ 43 + Subject: pl.UserID, 44 + ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.accessTokenTTL)), 45 + }) 46 + return tok.SignedString([]byte(j.signingKey)) 47 +} 48 + 49 +func (j *JWTUtil) RefreshToken() (string, error) { 50 + b := make([]byte, 32) 51 + if _, err := rand.Read(b); err != nil { 52 + return "", err 53 + } 54 + return hex.EncodeToString(b), nil 55 +} 56 + 57 +func (j *JWTUtil) Parse(token string) (Payload, error) { 58 + var claims jwt.RegisteredClaims 59 + _, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (interface{}, error) { 60 + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { 61 + return nil, errors.New("unexpected signing method") 62 + } 63 + return []byte(j.signingKey), nil 64 + }) 65 + return Payload{ 66 + UserID: claims.Subject, 67 + }, err 68 +}
A
internal/models/session.go
··· 1 +package models 2 + 3 +import ( 4 + "errors" 5 + "time" 6 + 7 + "github.com/gofrs/uuid/v5" 8 +) 9 + 10 +var ErrSessionNotFound = errors.New("user: session not found") 11 + 12 +type Session struct { 13 + ID uuid.UUID 14 + UserID uuid.UUID 15 + RefreshToken string 16 + ExpiresAt time.Time 17 +}
A
internal/models/user.go
··· 1 +package models 2 + 3 +import ( 4 + "errors" 5 + "net/mail" 6 + "time" 7 + 8 + "github.com/gofrs/uuid/v5" 9 +) 10 + 11 +var ( 12 + ErrUserEmailIsAlreadyInUse = errors.New("user: email is already in use") 13 + ErrUsernameIsAlreadyInUse = errors.New("user: username is already in use") 14 + 15 + ErrUserNotFound = errors.New("user: not found") 16 + ErrUserWrongCredentials = errors.New("user: wrong credentials") 17 +) 18 + 19 +type User struct { 20 + ID uuid.UUID 21 + Username string 22 + Email string 23 + Password string 24 + CreatedAt time.Time 25 + LastLoginAt time.Time 26 +} 27 + 28 +func (u User) Validate() error { 29 + // NOTE: there's probably a better way to validate emails 30 + _, err := mail.ParseAddress(u.Email) 31 + if err != nil { 32 + return errors.New("user: invalid email") 33 + } 34 + 35 + if len(u.Password) < 6 { 36 + return errors.New("user: password too short, minimum 6 chars") 37 + } 38 + 39 + if len(u.Username) == 0 { 40 + return errors.New("user: username is required") 41 + } 42 + 43 + return nil 44 +}
A
internal/models/user_test.go
··· 1 +package models 2 + 3 +import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/require" 7 +) 8 + 9 +func TestUser_Validate(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + fail bool 13 + 14 + username string 15 + email string 16 + password string 17 + }{ 18 + { 19 + name: "valid", 20 + fail: false, 21 + email: "test@example.org", 22 + username: "iuserarchbtw", 23 + password: "superhardasspassword", 24 + }, 25 + { 26 + name: "all fields empty", 27 + fail: true, 28 + email: "", 29 + username: "", 30 + password: "", 31 + }, 32 + { 33 + name: "invalid email", 34 + fail: true, 35 + email: "test", 36 + username: "iuserarchbtw", 37 + password: "superhardasspassword", 38 + }, 39 + { 40 + name: "invalid password", 41 + fail: true, 42 + email: "test@example.org", 43 + username: "iuserarchbtw", 44 + password: "12345", 45 + }, 46 + { 47 + name: "invalid username", 48 + fail: true, 49 + email: "test@example.org", 50 + username: "", 51 + password: "superhardasspassword", 52 + }, 53 + } 54 + 55 + for _, tt := range tests { 56 + t.Run(tt.name, func(t *testing.T) { 57 + err := User{ 58 + Username: tt.username, 59 + Email: tt.email, 60 + Password: tt.password, 61 + }.Validate() 62 + 63 + if tt.fail { 64 + require.Error(t, err) 65 + } else { 66 + require.NoError(t, err) 67 + } 68 + }) 69 + } 70 +}
M
internal/service/usersrv/usersrv.go
··· 1 1 package usersrv 2 2 3 -import "github.com/olexsmir/onasty/internal/store/psql/userepo" 3 +import ( 4 + "context" 5 + "errors" 6 + "time" 7 + 8 + "github.com/gofrs/uuid/v5" 9 + "github.com/olexsmir/onasty/internal/dtos" 10 + "github.com/olexsmir/onasty/internal/hasher" 11 + "github.com/olexsmir/onasty/internal/jwtutil" 12 + "github.com/olexsmir/onasty/internal/models" 13 + "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" 14 + "github.com/olexsmir/onasty/internal/store/psql/userepo" 15 +) 4 16 5 17 type UserServicer interface { 6 - SignUp() error 18 + SignUp(ctx context.Context, inp dtos.CreateUserDTO) (uuid.UUID, error) 19 + SignIn(ctx context.Context, inp dtos.SignInDTO) (dtos.TokensDTO, error) 20 + RefreshTokens(ctx context.Context, refreshToken string) (dtos.TokensDTO, error) 21 + Logout(ctx context.Context, userID uuid.UUID) error 22 + 23 + ParseToken(token string) (jwtutil.Payload, error) 24 + CheckIfUserExists(ctx context.Context, userID uuid.UUID) (bool, error) 7 25 } 8 26 27 +var _ UserServicer = (*UserSrv)(nil) 28 + 9 29 type UserSrv struct { 10 - store userepo.UserStorer 30 + userstore userepo.UserStorer 31 + sessionstore sessionrepo.SessionStorer 32 + hasher hasher.Hasher 33 + jwtTokenizer jwtutil.JWTTokenizer 34 + 35 + refreshTokenExpiredAt time.Time 11 36 } 12 37 13 -func New(store userepo.UserStorer) UserServicer { 38 +func New( 39 + userstore userepo.UserStorer, 40 + sessionstore sessionrepo.SessionStorer, 41 + hasher hasher.Hasher, 42 + jwtTokenizer jwtutil.JWTTokenizer, 43 +) UserServicer { 14 44 return &UserSrv{ 15 - store: store, 45 + userstore: userstore, 46 + sessionstore: sessionstore, 47 + hasher: hasher, 48 + jwtTokenizer: jwtTokenizer, 16 49 } 17 50 } 18 51 19 -// type SignUp 20 -func (s *UserSrv) SignUp() error { 21 - return nil 52 +func (u *UserSrv) SignUp(ctx context.Context, inp dtos.CreateUserDTO) (uuid.UUID, error) { 53 + hashedPassword, err := u.hasher.Hash(inp.Password) 54 + if err != nil { 55 + return uuid.UUID{}, err 56 + } 57 + 58 + return u.userstore.Create(ctx, dtos.CreateUserDTO{ 59 + Username: inp.Username, 60 + Email: inp.Email, 61 + Password: hashedPassword, 62 + CreatedAt: inp.CreatedAt, 63 + LastLoginAt: inp.LastLoginAt, 64 + }) 65 +} 66 + 67 +func (u *UserSrv) SignIn(ctx context.Context, inp dtos.SignInDTO) (dtos.TokensDTO, error) { 68 + hashedPassword, err := u.hasher.Hash(inp.Password) 69 + if err != nil { 70 + return dtos.TokensDTO{}, err 71 + } 72 + 73 + user, err := u.userstore.GetUserByCredentials(ctx, inp.Email, hashedPassword) 74 + if err != nil { 75 + if errors.Is(err, models.ErrUserNotFound) { 76 + return dtos.TokensDTO{}, models.ErrUserWrongCredentials 77 + } 78 + return dtos.TokensDTO{}, err 79 + } 80 + 81 + tokens, err := u.getTokens(user.ID) 82 + if err != nil { 83 + return dtos.TokensDTO{}, err 84 + } 85 + 86 + if err := u.sessionstore.Set(ctx, user.ID, tokens.Refresh, u.refreshTokenExpiredAt); err != nil { 87 + return dtos.TokensDTO{}, err 88 + } 89 + 90 + return dtos.TokensDTO{ 91 + Access: tokens.Access, 92 + Refresh: tokens.Refresh, 93 + }, nil 94 +} 95 + 96 +func (u *UserSrv) Logout(ctx context.Context, userID uuid.UUID) error { 97 + return u.sessionstore.Delete(ctx, userID) 98 +} 99 + 100 +func (u *UserSrv) RefreshTokens(ctx context.Context, rtoken string) (dtos.TokensDTO, error) { 101 + userID, err := u.sessionstore.GetUserIDByRefreshToken(ctx, rtoken) 102 + if err != nil { 103 + return dtos.TokensDTO{}, err 104 + } 105 + 106 + tokens, err := u.getTokens(userID) 107 + if err != nil { 108 + return dtos.TokensDTO{}, err 109 + } 110 + 111 + err = u.sessionstore.Update(ctx, userID, rtoken, tokens.Refresh) 112 + 113 + return dtos.TokensDTO{ 114 + Access: tokens.Access, 115 + Refresh: tokens.Refresh, 116 + }, err 117 +} 118 + 119 +func (u *UserSrv) ParseToken(token string) (jwtutil.Payload, error) { 120 + return u.jwtTokenizer.Parse(token) 121 +} 122 + 123 +func (u UserSrv) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) { 124 + return u.userstore.CheckIfUserExists(ctx, id) 125 +} 126 + 127 +func (u UserSrv) getTokens(userID uuid.UUID) (dtos.TokensDTO, error) { 128 + accessToken, err := u.jwtTokenizer.AccessToken(jwtutil.Payload{UserID: userID.String()}) 129 + if err != nil { 130 + return dtos.TokensDTO{}, err 131 + } 132 + 133 + refreshToken, err := u.jwtTokenizer.RefreshToken() 134 + if err != nil { 135 + return dtos.TokensDTO{}, err 136 + } 137 + 138 + return dtos.TokensDTO{ 139 + Access: accessToken, 140 + Refresh: refreshToken, 141 + }, err 22 142 }
A
internal/store/psql/sessionrepo/sessionrepo.go
··· 1 +package sessionrepo 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 SessionStorer interface { 16 + Set(ctx context.Context, usedID uuid.UUID, refreshToken string, expiresAt time.Time) error 17 + GetUserIDByRefreshToken(ctx context.Context, refreshToken string) (uuid.UUID, error) 18 + Update(ctx context.Context, userID uuid.UUID, refreshToken string, newRefreshToken string) error 19 + Delete(ctx context.Context, userID uuid.UUID) error 20 +} 21 + 22 +var _ SessionStorer = (*SessionRepo)(nil) 23 + 24 +type SessionRepo struct { 25 + db *psqlutil.DB 26 +} 27 + 28 +func New(db *psqlutil.DB) SessionStorer { 29 + return &SessionRepo{ 30 + db: db, 31 + } 32 +} 33 + 34 +func (s *SessionRepo) Set( 35 + ctx context.Context, 36 + userID uuid.UUID, 37 + refreshToken string, 38 + expiresAt time.Time, 39 +) error { 40 + query, args, err := pgq. 41 + Insert("sessions"). 42 + Columns("user_id", "refresh_token", "expires_at"). 43 + Values(userID, refreshToken, expiresAt). 44 + SQL() 45 + if err != nil { 46 + return err 47 + } 48 + 49 + _, err = s.db.Exec(ctx, query, args...) 50 + return err 51 +} 52 + 53 +func (s *SessionRepo) Update( 54 + ctx context.Context, 55 + userID uuid.UUID, 56 + refreshToken string, 57 + newRefreshToken string, 58 +) error { 59 + query := `--sql 60 +update sessions 61 +set refresh_token = $1 62 +where 63 + user_id = $2 64 + and refresh_token = $3 65 + and expires_at < now() 66 +` 67 + 68 + res, err := s.db.Exec(ctx, query, newRefreshToken, userID, refreshToken) 69 + if res.RowsAffected() != 1 { 70 + return models.ErrSessionNotFound 71 + } 72 + 73 + return err 74 +} 75 + 76 +func (s *SessionRepo) GetUserIDByRefreshToken( 77 + ctx context.Context, 78 + refreshToken string, 79 +) (uuid.UUID, error) { 80 + query, args, err := pgq. 81 + Select("user_id"). 82 + From("sessions"). 83 + Where(pgq.Eq{"refresh_token": refreshToken}). 84 + SQL() 85 + if err != nil { 86 + return uuid.UUID{}, err 87 + } 88 + 89 + var userID uuid.UUID 90 + err = s.db.QueryRow(ctx, query, args...).Scan(&userID) 91 + if errors.Is(err, pgx.ErrNoRows) { 92 + return uuid.UUID{}, models.ErrUserNotFound 93 + } 94 + 95 + return userID, err 96 +} 97 + 98 +func (s *SessionRepo) Delete(ctx context.Context, userID uuid.UUID) error { 99 + query := `--sql 100 +DELETE FROM sessions 101 +WHERE user_id = $1 102 +` 103 + 104 + _, err := s.db.Exec(ctx, query, userID) 105 + return err 106 +}
M
internal/store/psql/userepo/userepo.go
··· 1 1 package userepo 2 2 3 3 import ( 4 + "context" 5 + "errors" 6 + 4 7 "github.com/gofrs/uuid/v5" 8 + "github.com/henvic/pgq" 9 + "github.com/jackc/pgx/v5" 10 + "github.com/olexsmir/onasty/internal/dtos" 11 + "github.com/olexsmir/onasty/internal/models" 5 12 "github.com/olexsmir/onasty/internal/store/psqlutil" 6 13 ) 7 14 8 15 type UserStorer interface { 9 - SignUp(inp SignUpInput) (uuid.UUID, error) 16 + Create(ctx context.Context, inp dtos.CreateUserDTO) (uuid.UUID, error) 17 + GetUserByCredentials(ctx context.Context, email, password string) (dtos.UserDTO, error) 18 + 19 + CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) 10 20 } 21 + 22 +var _ UserStorer = (*UserRepo)(nil) 11 23 12 24 type UserRepo struct { 13 25 db *psqlutil.DB 14 26 } 15 27 16 -func New(db *psqlutil.DB) UserStorer { 28 +func New(db *psqlutil.DB) *UserRepo { 17 29 return &UserRepo{ 18 30 db: db, 19 31 } 20 32 } 21 33 22 -type SignUpInput struct{} 34 +func (r *UserRepo) Create(ctx context.Context, inp dtos.CreateUserDTO) (uuid.UUID, error) { 35 + query, args, err := pgq. 36 + Insert("users"). 37 + Columns("username", "email", "password", "created_at", "last_login_at"). 38 + Values(inp.Username, inp.Email, inp.Password, inp.CreatedAt, inp.LastLoginAt). 39 + Returning("id"). 40 + SQL() 41 + if err != nil { 42 + return uuid.UUID{}, err 43 + } 23 44 24 -func (r *UserRepo) SignUp(_ SignUpInput) (uuid.UUID, error) { 25 - return uuid.UUID{}, nil 45 + var id uuid.UUID 46 + err = r.db.QueryRow(ctx, query, args...).Scan(&id) 47 + 48 + // FIXME: somehow this does return errors but i can't errors.Is them in api layer 49 + if psqlutil.IsDuplicateErr(err, "users_username_key") { 50 + return uuid.UUID{}, models.ErrUsernameIsAlreadyInUse 51 + } 52 + 53 + if psqlutil.IsDuplicateErr(err, "users_email_key") { 54 + return uuid.UUID{}, models.ErrUserEmailIsAlreadyInUse 55 + } 56 + 57 + return id, err 58 +} 59 + 60 +func (r *UserRepo) GetUserByCredentials( 61 + ctx context.Context, 62 + email, password string, 63 +) (dtos.UserDTO, error) { 64 + query, args, err := pgq. 65 + Select("id", "username", "email", "password", "created_at", "last_login_at"). 66 + From("users"). 67 + Where(pgq.Eq{ 68 + "email": email, 69 + "password": password, 70 + }). 71 + SQL() 72 + if err != nil { 73 + return dtos.UserDTO{}, err 74 + } 75 + 76 + var user dtos.UserDTO 77 + err = r.db.QueryRow(ctx, query, args...). 78 + Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.LastLoginAt) 79 + if errors.Is(err, pgx.ErrNoRows) { 80 + return dtos.UserDTO{}, models.ErrUserNotFound 81 + } 82 + 83 + return user, err 84 +} 85 + 86 +func (r *UserRepo) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) { 87 + var exists bool 88 + err := r.db.QueryRow( 89 + ctx, 90 + `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, 91 + id.String(), 92 + ).Scan(&exists) 93 + if errors.Is(err, pgx.ErrNoRows) { 94 + return false, models.ErrUserNotFound 95 + } 96 + 97 + return exists, err 26 98 }
M
internal/store/psqlutil/psqlutil.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 7 + "github.com/jackc/pgconn" 6 8 pgxuuid "github.com/jackc/pgx-gofrs-uuid" 7 9 "github.com/jackc/pgx/v5" 8 10 "github.com/jackc/pgx/v5/pgxpool" ··· 39 41 db.Pool.Close() 40 42 return nil 41 43 } 44 + 45 +// IsDuplicateErr function that checks if the error is a duplicate key violation. 46 +func IsDuplicateErr(err error, constraintName ...string) bool { 47 + var pgErr *pgconn.PgError 48 + if errors.As(err, &pgErr) { 49 + if len(constraintName) == 0 || len(constraintName) == 1 { 50 + return pgErr.Code == "23505" && // unique_violation 51 + pgErr.ConstraintName == constraintName[0] 52 + } 53 + return pgErr.Code == "23505" // unique_violation 54 + } 55 + return false 56 +}
M
internal/transport/http/apiv1/auth.go
··· 2 2 3 3 import ( 4 4 "net/http" 5 + "time" 5 6 6 7 "github.com/gin-gonic/gin" 8 + "github.com/olexsmir/onasty/internal/dtos" 9 + "github.com/olexsmir/onasty/internal/models" 7 10 ) 8 11 9 12 type signUpRequest struct { 13 + Username string `json:"username"` 10 14 Email string `json:"email"` 11 15 Password string `json:"password"` 12 16 } ··· 17 21 newError(c, http.StatusBadRequest, "invalid request") 18 22 return 19 23 } 24 + 25 + user := models.User{ 26 + Username: req.Username, 27 + Email: req.Email, 28 + Password: req.Password, 29 + CreatedAt: time.Now(), 30 + LastLoginAt: time.Now(), 31 + } 32 + if err := user.Validate(); err != nil { 33 + // TODO: find a way to return all errors at once 34 + newErrorStatus(c, http.StatusBadRequest, err.Error()) 35 + return 36 + } 37 + 38 + if _, err := a.usersrv.SignUp(c.Request.Context(), dtos.CreateUserDTO{ 39 + Username: user.Username, 40 + Email: user.Email, 41 + Password: user.Password, 42 + CreatedAt: user.CreatedAt, 43 + LastLoginAt: user.LastLoginAt, 44 + }); err != nil { 45 + errorResponse(c, err) 46 + return 47 + } 48 + 49 + c.Status(http.StatusCreated) 20 50 } 21 51 22 -func (a *APIV1) signInHandler(_ *gin.Context) {} 52 +type signInRequest struct { 53 + Email string `json:"email"` 54 + Password string `json:"password"` 55 +} 23 56 24 -func (a *APIV1) refreshTokensHandler(_ *gin.Context) {} 57 +type signInResponse struct { 58 + AccessToken string `json:"access_token"` 59 + RefreshToken string `json:"refresh_token"` 60 +} 25 61 26 -func (a *APIV1) logOutHandler(_ *gin.Context) {} 62 +func (a *APIV1) signInHandler(c *gin.Context) { 63 + var req signInRequest 64 + if err := c.ShouldBindJSON(&req); err != nil { 65 + newError(c, http.StatusBadRequest, "invalid request") 66 + return 67 + } 68 + 69 + toks, err := a.usersrv.SignIn(c.Request.Context(), dtos.SignInDTO{ 70 + Email: req.Email, 71 + Password: req.Password, 72 + }) 73 + if err != nil { 74 + errorResponse(c, err) 75 + return 76 + } 77 + 78 + c.JSON(http.StatusOK, signInResponse{ 79 + AccessToken: toks.Access, 80 + RefreshToken: toks.Refresh, 81 + }) 82 +} 83 + 84 +type refreshTokenRequest struct { 85 + RefreshToken string `json:"refresh_token"` 86 +} 87 + 88 +func (a *APIV1) refreshTokensHandler(c *gin.Context) { 89 + var req refreshTokenRequest 90 + if err := c.ShouldBindJSON(&req); err != nil { 91 + newError(c, http.StatusBadRequest, "invalid request") 92 + return 93 + } 94 + 95 + toks, err := a.usersrv.RefreshTokens(c.Request.Context(), req.RefreshToken) 96 + if err != nil { 97 + errorResponse(c, err) 98 + return 99 + } 100 + 101 + c.JSON(http.StatusOK, signInResponse{ 102 + AccessToken: toks.Access, 103 + RefreshToken: toks.Refresh, 104 + }) 105 +} 106 + 107 +func (a *APIV1) logOutHandler(c *gin.Context) { 108 + if err := a.usersrv.Logout(c.Request.Context(), getUserID(c)); err != nil { 109 + errorResponse(c, err) 110 + return 111 + } 112 + 113 + c.Status(http.StatusNoContent) 114 +}
M
internal/transport/http/apiv1/middleware.go
··· 1 1 package apiv1 2 2 3 -import "github.com/gin-gonic/gin" 3 +import ( 4 + "context" 5 + "errors" 6 + "strings" 7 + 8 + "github.com/gin-gonic/gin" 9 + "github.com/gofrs/uuid/v5" 10 + "github.com/olexsmir/onasty/internal/service/usersrv" 11 +) 12 + 13 +var ErrUnauthorized = errors.New("unauthorized") 14 + 15 +const userIDCtxKey = "userID" 16 + 17 +func (a *APIV1) authorizedMiddleware(c *gin.Context) { 18 + token, ok := getTokenFromAuthHeaders(c) 19 + if !ok { 20 + errorResponse(c, ErrUnauthorized) 21 + return 22 + } 23 + 24 + ok, err := checkIfUserIsReal(c.Request.Context(), token, a.usersrv) 25 + if err != nil { 26 + errorResponse(c, err) 27 + return 28 + } 29 + 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 + } 39 + 40 + c.Next() 41 +} 42 + 43 +//nolint:unused // TODO: remove me later 44 +func (a *APIV1) couldBeAuthorizedMiddleware(c *gin.Context) { 45 + token, ok := getTokenFromAuthHeaders(c) 46 + if ok { 47 + ok, err := checkIfUserIsReal(c.Request.Context(), token, a.usersrv) 48 + if err != nil { 49 + errorResponse(c, err) 50 + return 51 + } 52 + 53 + if !ok { 54 + errorResponse(c, ErrUnauthorized) 55 + return 56 + } 57 + 58 + if err := saveUserIDToCtx(c, a.usersrv, token); err != nil { 59 + newInternalError(c, err) 60 + return 61 + } 62 + } 63 + 64 + c.Next() 65 +} 66 + 67 +//nolint:unused // TODO: remove me later 68 +func (a *APIV1) isUserAuthorized(c *gin.Context) bool { 69 + return !getUserID(c).IsNil() 70 +} 71 + 72 +func getTokenFromAuthHeaders(c *gin.Context) (token string, ok bool) { //nolint:nonamedreturns 73 + header := c.GetHeader("Authorization") 74 + if header == "" { 75 + return "", false 76 + } 77 + 78 + headerParts := strings.Split(header, " ") 79 + if len(headerParts) != 2 && headerParts[0] != "Bearer" { 80 + return "", false 81 + } 82 + 83 + if len(headerParts[1]) == 0 { 84 + return "", false 85 + } 86 + 87 + return headerParts[1], true 88 +} 89 + 90 +func saveUserIDToCtx(c *gin.Context, us usersrv.UserServicer, token string) error { 91 + pl, err := us.ParseToken(token) 92 + if err != nil { 93 + return err 94 + } 95 + 96 + c.Set(userIDCtxKey, pl.UserID) 4 97 5 -func (a *APIV1) authorizedMiddleware(_ *gin.Context) {} 98 + return nil 99 +} 6 100 7 -func (a *APIV1) couldBeAuthorizedMiddleware(_ *gin.Context) { //nolint:unused 101 +// getUserId returns userId from the context 102 +// getting user id is only possible if user is authorized 103 +func getUserID(c *gin.Context) uuid.UUID { 104 + userID, exists := c.Get(userIDCtxKey) 105 + if !exists { 106 + return uuid.Nil 107 + } 108 + return uuid.Must(uuid.FromString(userID.(string))) 109 +} 110 + 111 +func checkIfUserIsReal( 112 + ctx context.Context, 113 + accessToken string, 114 + us usersrv.UserServicer, 115 +) (bool, error) { 116 + parsedToken, err := us.ParseToken(accessToken) 117 + if err != nil { 118 + return false, err 119 + } 120 + 121 + return us.CheckIfUserExists( 122 + ctx, 123 + uuid.Must(uuid.FromString(parsedToken.UserID)), 124 + ) 8 125 }
M
internal/transport/http/apiv1/response.go
··· 1 1 package apiv1 2 2 3 3 import ( 4 + "errors" 4 5 "log/slog" 5 6 "net/http" 6 7 7 8 "github.com/gin-gonic/gin" 9 + "github.com/olexsmir/onasty/internal/models" 8 10 ) 9 11 10 12 type response struct { 11 13 Message string `json:"message"` 12 14 } 13 15 14 -func newError(c *gin.Context, status int, msg string) { 15 - slog.With("status", status).Error(msg) 16 +func errorResponse(c *gin.Context, err error) { 17 + if errors.Is(err, models.ErrUserEmailIsAlreadyInUse) || 18 + errors.Is(err, models.ErrUsernameIsAlreadyInUse) { 19 + newError(c, http.StatusBadRequest, err.Error()) 20 + return 21 + } 22 + 23 + if errors.Is(err, models.ErrUserNotFound) { 24 + newErrorStatus(c, http.StatusBadRequest, err.Error()) 25 + return 26 + } 27 + 28 + if errors.Is(err, ErrUnauthorized) || 29 + errors.Is(err, models.ErrUserWrongCredentials) { 30 + newErrorStatus(c, http.StatusUnauthorized, err.Error()) 31 + return 32 + } 33 + 34 + newInternalError(c, err) 35 +} 36 + 37 +func newError(c *gin.Context, status int, msg string) { //nolint:unparam // TODO: remove me later 38 + slog.Error(msg, "status", status) 16 39 c.AbortWithStatusJSON(status, response{msg}) 40 +} 41 + 42 +func newErrorStatus(c *gin.Context, status int, msg string) { 43 + slog.Error(msg, "status", status) 44 + c.AbortWithStatus(status) 17 45 } 18 46 19 47 func newInternalError(c *gin.Context, err error, msg ...string) {
A
migrations/20240613092407_users.up.sql
··· 1 +create table users ( 2 + id uuid primary key default uuid_generate_v4(), 3 + username varchar(255) not null unique, 4 + email varchar(255) not null unique, 5 + password varchar(255) not null, 6 + activated boolean not null default false, 7 + created_at timestamptz not null default now(), 8 + last_login_at timestamptz not null default now() 9 +);