all repos

onasty @ 2cd7240

a one-time notes service
33 files changed, 1313 insertions(+), 68 deletions(-)
feat: implement auth (#4)

* feat: add mighrations

* feat(auth): impl sign up

* feat: add jwtutil helper package

* refactor: dtos
also almost implement signing in

* feat(auth): finish sign in

* refactor(http): some renaming

* fix(jwtutil): key signing now work!!!

* update .env.example

* feat(auth): add middlewares

* feat: impl sessions repo

* feat(usersrv): store refresh token in db, and add some minor changes

* refactor(userepo): check if interface is implemented

* feat(api): implement logout

* refactor(usersrv): fix typo

* make app being able to run

* remove linter things

* feat: add data validation

* feat(usersrv): refresh tokens

* feat(api): refresh tokens

* fix: me being stupid

* fix(sessionrepo): somehow it fixed logout

* chore(taskfile): add task for stopping docker

* refactor(http): renaming

* test(e2e): fix connection to db

* test(e2e): add helper functions

* refactor(hasher): add unified Hasher interface

* test(models): test user's validator

* test(e2e): test user signing up

* test(e2e): add fail cases for sign up

* fixup! test(e2e): test user signing up

* refactor(e2e): change reciver name on suite

* fixup! refactor(e2e): change reciver name on suite

* refactor(e2e): some renaming

* refactor(apiv1): replay with error correctly :)

* feat(e2e): add few new utils

* test(e2e): test signin

* i have found a bug...

* feat(auth): handle wrong user provided credentials in more "correct" way

* add one more TODO comment

* tests(e2e): handle wrong signin cases

* feat(e2e): add util for parsing jwt tokens

* docs(e2e): add comments

* fix(sessionrepo): now it doesnt fail

* feat(e2e): add auth support for httpRequest util

* test(e2e): refresh tokens

* test(e2e): logout

* refactor(e2e): nameing

* chore(Taskfile): add tasks for running all tests

* chore(ci): move linter into sep action

* test(e2e): add fail test case for refresh token

* fix(auth): do not return internal error

* make the linter not angry at me

* refactor(e2e): renaming

* feat: check if user is real in auth middleware

* docs(jwtutil): add comments
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2024-07-17 02:23:56 +0300
Parent: a667923
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 Taskfile.yml
···
        20
        20
           docker:up:

      
        21
        21
             - docker compose up -d

      
        22
        22
         

      
        
        23
        +  docker:down:

      
        
        24
        +    aliases: [docker:stop]

      
        
        25
        +    cmds:

      
        
        26
        +      - docker compose stop

      
        
        27
        +

      
        
        28
        +  test:

      
        
        29
        +    - task: test:unit

      
        
        30
        +    - task: test:e2e

      
        
        31
        +

      
        23
        32
           test:unit:

      
        24
        33
             - go test -v --short ./...

      
        25
        34
         

      
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/token.go
···
        
        1
        +package dtos

      
        
        2
        +

      
        
        3
        +type TokensDTO struct {

      
        
        4
        +	Access  string

      
        
        5
        +	Refresh string

      
        
        6
        +}

      
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/hasher.go
···
        
        1
        +package hasher

      
        
        2
        +

      
        
        3
        +type Hasher interface {

      
        
        4
        +	// Hash takes a string as input and returns its hash

      
        
        5
        +	Hash(string) (string, error)

      
        
        6
        +}

      
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/apiv1.go
···
        6
        6
         )

      
        7
        7
         

      
        8
        8
         type APIV1 struct {

      
        9
        
        -	userSrv usersrv.UserServicer

      
        
        9
        +	usersrv usersrv.UserServicer

      
        10
        10
         }

      
        11
        11
         

      
        12
        12
         func NewAPIV1(us usersrv.UserServicer) *APIV1 {

      
        13
        13
         	return &APIV1{

      
        14
        
        -		userSrv: us,

      
        
        14
        +		usersrv: us,

      
        15
        15
         	}

      
        16
        16
         }

      
        17
        17
         

      
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) {

      
M internal/transport/http/middlewares.go
···
        7
        7
         	"github.com/gin-gonic/gin"

      
        8
        8
         )

      
        9
        9
         

      
        
        10
        +// TODO: include requiest id

      
        10
        11
         func (t *Transport) logger() gin.HandlerFunc {

      
        11
        12
         	return func(c *gin.Context) {

      
        12
        13
         		start := time.Now()

      
A migrations/20240613092407_users.down.sql
···
        
        1
        +drop table if exists users;

      
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
        +);

      
A migrations/20240613092532_sessions.down.sql
···
        
        1
        +drop table if exists sessions;

      
A migrations/20240613092532_sessions.up.sql
···
        
        1
        +create table sessions (

      
        
        2
        +  id uuid primary key default uuid_generate_v4(),

      
        
        3
        +  user_id uuid references users (id),

      
        
        4
        +  refresh_token varchar(255) not null unique,

      
        
        5
        +  expires_at timestamptz not null

      
        
        6
        +);