From 1ecdd80b2cb51eb206b447e0d64b4cbe4f6d4210 Mon Sep 17 00:00:00 2001 From: cukhoaimon Date: Sun, 4 Feb 2024 22:06:04 +0700 Subject: [PATCH 1/3] feat: add session table to migration --- .idea/sqldialects.xml | 1 + README.md | Bin 30 -> 404 bytes db/migration/000001_init_schema.down.sql | 1 + db/migration/000003_add_sessions.down.sql | 1 + db/migration/000003_add_sessions.up.sql | 11 +++++++++++ 5 files changed, 14 insertions(+) create mode 100644 db/migration/000003_add_sessions.down.sql create mode 100644 db/migration/000003_add_sessions.up.sql diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index b6f6d98..4458285 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -3,6 +3,7 @@ + \ No newline at end of file diff --git a/README.md b/README.md index a38e82501f2da2d7cbd3610954db1a10e60e2d5c..d7e99221ddb9475ea1c15ffacddea7f33ce78278 100644 GIT binary patch literal 404 zcmZvY%L>9k3`Or+@E-y4Z)5HCon~wz=5uPjGfPU12SmNbK#olw6s~)pF diff --git a/db/migration/000001_init_schema.down.sql b/db/migration/000001_init_schema.down.sql index db43f93..68613a5 100644 --- a/db/migration/000001_init_schema.down.sql +++ b/db/migration/000001_init_schema.down.sql @@ -1,3 +1,4 @@ DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS transfers; DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/db/migration/000003_add_sessions.down.sql b/db/migration/000003_add_sessions.down.sql new file mode 100644 index 0000000..9a8955b --- /dev/null +++ b/db/migration/000003_add_sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "sessions"; \ No newline at end of file diff --git a/db/migration/000003_add_sessions.up.sql b/db/migration/000003_add_sessions.up.sql new file mode 100644 index 0000000..39c9b51 --- /dev/null +++ b/db/migration/000003_add_sessions.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE "sessions" ( + "id" uuid PRIMARY KEY, + "username" varchar NOT NULL, + "refresh_token" varchar NOT NULL, + "user_agent" varchar NOT NULL, + "client_ip" varchar UNIQUE NOT NULL, + "expires_at" timestamptz NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT (now()) +); + +ALTER TABLE "sessions" ADD FOREIGN KEY ("username") REFERENCES "users" ("username"); \ No newline at end of file From 8dff2cf7cb57d469d9fdcc8cf5c14e5f7e58d99a Mon Sep 17 00:00:00 2001 From: cukhoaimon Date: Sun, 4 Feb 2024 22:51:43 +0700 Subject: [PATCH 2/3] add session management --- .idea/sqldialects.xml | 1 + api/middleware_test.go | 3 +- api/server.go | 1 + api/token.go | 81 ++++++++++++++++++++++++ api/token_test.go | 24 ++++++++ api/user.go | 50 +++++++++++++-- api/user_test.go | 4 ++ app.env | 3 +- db/migration/000003_add_sessions.up.sql | 3 +- db/mock/store.go | 31 ++++++++++ db/query/session.sql | 16 +++++ db/sqlc/models.go | 13 ++++ db/sqlc/querier.go | 4 ++ db/sqlc/session.sql.go | 82 +++++++++++++++++++++++++ main.go | 3 +- token/jwt_maker.go | 9 +-- token/jwt_maker_test.go | 9 ++- token/maker.go | 2 +- token/paseto_maker.go | 7 ++- token/paseto_maker_test.go | 16 +++-- utils/config.go | 11 ++-- 21 files changed, 340 insertions(+), 33 deletions(-) create mode 100644 api/token.go create mode 100644 api/token_test.go create mode 100644 db/query/session.sql create mode 100644 db/sqlc/session.sql.go diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index 4458285..0e0a403 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -4,6 +4,7 @@ + \ No newline at end of file diff --git a/api/middleware_test.go b/api/middleware_test.go index 1238f8d..444e405 100644 --- a/api/middleware_test.go +++ b/api/middleware_test.go @@ -20,8 +20,9 @@ func addAuthorization( username string, duration time.Duration, ) { - accessToken, err := tokenMaker.CreateToken(username, duration) + accessToken, payload, err := tokenMaker.CreateToken(username, duration) require.Nil(t, err) + require.NotEmpty(t, payload) require.NotEmpty(t, accessToken) authorizationHeader := fmt.Sprintf("%s %s", authorizationType, accessToken) diff --git a/api/server.go b/api/server.go index be03cfa..f583001 100644 --- a/api/server.go +++ b/api/server.go @@ -45,6 +45,7 @@ func (server *Server) setupRouter() { router := gin.Default() router.POST("/api/v1/user", server.createUser) router.POST("/api/v1/user/login", server.loginUser) + router.POST("/api/v1/user/token/renew_access", server.renewAccessTokenUser) authRoutes := router.Group("/").Use(authMiddleware(server.tokenMaker)) diff --git a/api/token.go b/api/token.go new file mode 100644 index 0000000..b521b3e --- /dev/null +++ b/api/token.go @@ -0,0 +1,81 @@ +package api + +import ( + "database/sql" + "errors" + "github.com/gin-gonic/gin" + "net/http" + "time" +) + +type renewAccessTokenRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +type renewAccessTokenResponse struct { + AccessToken string `json:"access_token"` + AccessTokenExpiresAt time.Time `json:"access_token_expires_at"` +} + +func (server *Server) renewAccessTokenUser(ctx *gin.Context) { + var req renewAccessTokenRequest + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return + } + + refreshPayload, err := server.tokenMaker.VerifyToken(req.RefreshToken) + + session, err := server.store.GetSession(ctx, refreshPayload.Id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + ctx.JSON(http.StatusNotFound, errorResponse(err)) + return + } + + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + + if session.RefreshToken != req.RefreshToken { + err = errors.New("mismatched session token") + ctx.JSON(http.StatusUnauthorized, errorResponse(err)) + return + } + + if session.IsBlocked { + err = errors.New("target session is blocked") + ctx.JSON(http.StatusUnauthorized, errorResponse(err)) + return + } + + if session.Username != refreshPayload.Username { + err = errors.New("incorrect session user") + ctx.JSON(http.StatusUnauthorized, errorResponse(err)) + return + } + + if time.Now().After(session.ExpiresAt) { + err = errors.New("expired session") + ctx.JSON(http.StatusUnauthorized, errorResponse(err)) + return + } + + token, accessTokenPayload, err := server.tokenMaker.CreateToken( + refreshPayload.Username, + server.config.TokenDuration, + ) + if err != nil { + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + + res := renewAccessTokenResponse{ + AccessToken: token, + AccessTokenExpiresAt: accessTokenPayload.ExpiredAt, + } + + ctx.JSON(http.StatusOK, res) + return +} diff --git a/api/token_test.go b/api/token_test.go new file mode 100644 index 0000000..b1796d4 --- /dev/null +++ b/api/token_test.go @@ -0,0 +1,24 @@ +package api + +import ( + mockdb "github.com/cukhoaimon/SimpleBank/db/mock" + "github.com/gin-gonic/gin" + "net/http/httptest" + "testing" +) + +func TestServer_renewAccessTokenUser(t *testing.T) { + tests := []struct { + name string + body gin.H + buildStubs func(store *mockdb.MockStore) + checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder) + }{ + { /* TODO: add test cases*/ }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + }) + } +} diff --git a/api/user.go b/api/user.go index 5a0b14a..eb8b8d5 100644 --- a/api/user.go +++ b/api/user.go @@ -6,6 +6,7 @@ import ( db "github.com/cukhoaimon/SimpleBank/db/sqlc" "github.com/cukhoaimon/SimpleBank/utils" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/lib/pq" "net/http" "time" @@ -73,8 +74,12 @@ type loginRequest struct { } type loginResponse struct { - AccessToken string `json:"access_token"` - User userResponse `json:"user"` + SessionID uuid.UUID `json:"session_id"` + AccessToken string `json:"access_token"` + AccessTokenExpiresAt time.Time `json:"access_token_expires_at"` + RefreshToken string `json:"refresh_token"` + RefreshTokenExpiresAt time.Time `json:"refresh_token_expires_at"` + User userResponse `json:"user"` } func (server *Server) loginUser(ctx *gin.Context) { @@ -102,15 +107,50 @@ func (server *Server) loginUser(ctx *gin.Context) { return } - token, err := server.tokenMaker.CreateToken(req.Username, server.config.TokenDuration) + token, accessTokenPayload, err := server.tokenMaker.CreateToken(req.Username, server.config.TokenDuration) if err != nil { ctx.JSON(http.StatusInternalServerError, errorResponse(err)) return } + refreshToken, refreshTokenPayload, err := server.tokenMaker.CreateToken(req.Username, server.config.RefreshTokenDuration) + if err != nil { + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + + arg := db.CreateSessionParams{ + ID: accessTokenPayload.Id, + Username: accessTokenPayload.Username, + RefreshToken: refreshToken, + UserAgent: ctx.Request.UserAgent(), + ClientIp: ctx.ClientIP(), + IsBlocked: false, + ExpiresAt: refreshTokenPayload.ExpiredAt, + } + + session, err := server.store.CreateSession(ctx, arg) + if err != nil { + var pqErr *pq.Error + if errors.As(err, &pqErr) { + switch pqErr.Code.Name() { + case "unique_violation": + ctx.JSON(http.StatusForbidden, errorResponse(err)) + return + } + } + + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + res := loginResponse{ - AccessToken: token, - User: makeUserResponse(user), + SessionID: session.ID, + AccessToken: token, + AccessTokenExpiresAt: accessTokenPayload.ExpiredAt, + RefreshToken: refreshToken, + RefreshTokenExpiresAt: refreshTokenPayload.ExpiredAt, + User: makeUserResponse(user), } ctx.JSON(http.StatusOK, res) diff --git a/api/user_test.go b/api/user_test.go index d569b8f..e32addb 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -176,6 +176,10 @@ func TestServer_loginUser(t *testing.T) { "password": password, }, buildStubs: func(store *mockdb.MockStore) { + store.EXPECT(). + CreateSession(gomock.Any(), gomock.Any()). + Times(1) + store.EXPECT(). GetUser(gomock.Any(), gomock.Eq(user.Username)). Times(1). diff --git a/app.env b/app.env index 59d38d6..beb4e27 100644 --- a/app.env +++ b/app.env @@ -2,4 +2,5 @@ DB_DRIVER=postgres DB_SOURCE=postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable SERVER_ADDRESS=0.0.0.0:8080 TOKEN_SYMMETRIC_KEY=12345678901234567890123456789012 -TOKEN_DURATION=15m \ No newline at end of file +TOKEN_DURATION=15m +REFRESH_TOKEN_DURATION=24h \ No newline at end of file diff --git a/db/migration/000003_add_sessions.up.sql b/db/migration/000003_add_sessions.up.sql index 39c9b51..da4c3c6 100644 --- a/db/migration/000003_add_sessions.up.sql +++ b/db/migration/000003_add_sessions.up.sql @@ -3,7 +3,8 @@ CREATE TABLE "sessions" ( "username" varchar NOT NULL, "refresh_token" varchar NOT NULL, "user_agent" varchar NOT NULL, - "client_ip" varchar UNIQUE NOT NULL, + "client_ip" varchar NOT NULL, + "is_blocked" boolean NOT NULL DEFAULT false, "expires_at" timestamptz NOT NULL, "created_at" timestamptz NOT NULL DEFAULT (now()) ); diff --git a/db/mock/store.go b/db/mock/store.go index e483fd4..dcc4e9f 100644 --- a/db/mock/store.go +++ b/db/mock/store.go @@ -10,6 +10,7 @@ import ( db "github.com/cukhoaimon/SimpleBank/db/sqlc" gomock "github.com/golang/mock/gomock" + uuid "github.com/google/uuid" ) // MockStore is a mock of Store interface. @@ -80,6 +81,21 @@ func (mr *MockStoreMockRecorder) CreateEntry(arg0, arg1 interface{}) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateEntry", reflect.TypeOf((*MockStore)(nil).CreateEntry), arg0, arg1) } +// CreateSession mocks base method. +func (m *MockStore) CreateSession(arg0 context.Context, arg1 db.CreateSessionParams) (db.Session, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateSession", arg0, arg1) + ret0, _ := ret[0].(db.Session) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateSession indicates an expected call of CreateSession. +func (mr *MockStoreMockRecorder) CreateSession(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSession", reflect.TypeOf((*MockStore)(nil).CreateSession), arg0, arg1) +} + // CreateTransfer mocks base method. func (m *MockStore) CreateTransfer(arg0 context.Context, arg1 db.CreateTransferParams) (db.Transfer, error) { m.ctrl.T.Helper() @@ -169,6 +185,21 @@ func (mr *MockStoreMockRecorder) GetEntry(arg0, arg1 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEntry", reflect.TypeOf((*MockStore)(nil).GetEntry), arg0, arg1) } +// GetSession mocks base method. +func (m *MockStore) GetSession(arg0 context.Context, arg1 uuid.UUID) (db.Session, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSession", arg0, arg1) + ret0, _ := ret[0].(db.Session) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSession indicates an expected call of GetSession. +func (mr *MockStoreMockRecorder) GetSession(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSession", reflect.TypeOf((*MockStore)(nil).GetSession), arg0, arg1) +} + // GetTransfer mocks base method. func (m *MockStore) GetTransfer(arg0 context.Context, arg1 int64) (db.Transfer, error) { m.ctrl.T.Helper() diff --git a/db/query/session.sql b/db/query/session.sql new file mode 100644 index 0000000..29d57d1 --- /dev/null +++ b/db/query/session.sql @@ -0,0 +1,16 @@ +-- name: CreateSession :one +INSERT INTO sessions ( + "id", + "username", + "refresh_token", + "user_agent", + "client_ip", + "is_blocked", + "expires_at" +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) RETURNING *; + +-- name: GetSession :one +SELECT * FROM sessions +WHERE id = $1 LIMIT 1; diff --git a/db/sqlc/models.go b/db/sqlc/models.go index ae9f3fb..33fd055 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -6,6 +6,8 @@ package db import ( "time" + + "github.com/google/uuid" ) type Account struct { @@ -24,6 +26,17 @@ type Entry struct { CreatedAt time.Time `json:"created_at"` } +type Session struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` + RefreshToken string `json:"refresh_token"` + UserAgent string `json:"user_agent"` + ClientIp string `json:"client_ip"` + IsBlocked bool `json:"is_blocked"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + type Transfer struct { ID int64 `json:"id"` FromAccountID int64 `json:"from_account_id"` diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 0aa5519..a933b0a 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -6,18 +6,22 @@ package db import ( "context" + + "github.com/google/uuid" ) type Querier interface { AddAccountBalance(ctx context.Context, arg AddAccountBalanceParams) (Account, error) CreateAccount(ctx context.Context, arg CreateAccountParams) (Account, error) CreateEntry(ctx context.Context, arg CreateEntryParams) (Entry, error) + CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) CreateTransfer(ctx context.Context, arg CreateTransferParams) (Transfer, error) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) DeleteAccount(ctx context.Context, id int64) error GetAccount(ctx context.Context, id int64) (Account, error) GetAccountForUpdate(ctx context.Context, id int64) (Account, error) GetEntry(ctx context.Context, id int64) (Entry, error) + GetSession(ctx context.Context, id uuid.UUID) (Session, error) GetTransfer(ctx context.Context, id int64) (Transfer, error) GetUser(ctx context.Context, username string) (User, error) ListAccounts(ctx context.Context, arg ListAccountsParams) ([]Account, error) diff --git a/db/sqlc/session.sql.go b/db/sqlc/session.sql.go new file mode 100644 index 0000000..9a874c0 --- /dev/null +++ b/db/sqlc/session.sql.go @@ -0,0 +1,82 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: session.sql + +package db + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const createSession = `-- name: CreateSession :one +INSERT INTO sessions ( + "id", + "username", + "refresh_token", + "user_agent", + "client_ip", + "is_blocked", + "expires_at" +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) RETURNING id, username, refresh_token, user_agent, client_ip, is_blocked, expires_at, created_at +` + +type CreateSessionParams struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` + RefreshToken string `json:"refresh_token"` + UserAgent string `json:"user_agent"` + ClientIp string `json:"client_ip"` + IsBlocked bool `json:"is_blocked"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { + row := q.db.QueryRowContext(ctx, createSession, + arg.ID, + arg.Username, + arg.RefreshToken, + arg.UserAgent, + arg.ClientIp, + arg.IsBlocked, + arg.ExpiresAt, + ) + var i Session + err := row.Scan( + &i.ID, + &i.Username, + &i.RefreshToken, + &i.UserAgent, + &i.ClientIp, + &i.IsBlocked, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const getSession = `-- name: GetSession :one +SELECT id, username, refresh_token, user_agent, client_ip, is_blocked, expires_at, created_at FROM sessions +WHERE id = $1 LIMIT 1 +` + +func (q *Queries) GetSession(ctx context.Context, id uuid.UUID) (Session, error) { + row := q.db.QueryRowContext(ctx, getSession, id) + var i Session + err := row.Scan( + &i.ID, + &i.Username, + &i.RefreshToken, + &i.UserAgent, + &i.ClientIp, + &i.IsBlocked, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} diff --git a/main.go b/main.go index 42ad9ab..5f177aa 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,8 @@ import ( func main() { config, err := utils.LoadConfig(".") if err != nil { - log.Fatal("Cannot load configuration file") + + log.Fatal(err.Error()) } conn, err := sql.Open(config.DBDriver, config.DBSource) diff --git a/token/jwt_maker.go b/token/jwt_maker.go index e6f46a9..eb0b41c 100644 --- a/token/jwt_maker.go +++ b/token/jwt_maker.go @@ -32,20 +32,21 @@ func NewJWTMaker(secretKey string) (Maker, error) { } // CreateToken create a new jwt token -func (J JWTMaker) CreateToken(username string, duration time.Duration) (string, error) { +func (J JWTMaker) CreateToken(username string, duration time.Duration) (string, *Payload, error) { payload, err := NewPayload(username, duration) if err != nil { - return "", err + return "", nil, err } token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) key, err := base64.StdEncoding.DecodeString(J.secretKey) if err != nil { - return "", err + return "", nil, err } - return token.SignedString(key) + accessToken, err := token.SignedString(key) + return accessToken, payload, err } // VerifyToken verify if the jwt token is valid or not diff --git a/token/jwt_maker_test.go b/token/jwt_maker_test.go index cab301d..2d17452 100644 --- a/token/jwt_maker_test.go +++ b/token/jwt_maker_test.go @@ -32,17 +32,16 @@ func TestJWTMaker_Token(t *testing.T) { maker, err := NewJWTMaker(secretKey) require.Nil(t, err) - token, err := maker.CreateToken(args.Username, duration) + token, returnedPayload, err := maker.CreateToken(args.Username, duration) require.Nil(t, err) require.NotEmpty(t, token) + verifyPayload(t, args, returnedPayload) + payload, err := maker.VerifyToken(token) require.Nil(t, err) - require.NotEmpty(t, payload) - require.Equal(t, args.Username, payload.Username) - require.WithinDuration(t, args.IssuedAt, payload.IssuedAt, time.Second) - require.WithinDuration(t, args.ExpiredAt, payload.ExpiredAt, time.Second) + verifyPayload(t, args, payload) }, }, { diff --git a/token/maker.go b/token/maker.go index d1ee2a2..d38936b 100644 --- a/token/maker.go +++ b/token/maker.go @@ -5,7 +5,7 @@ import "time" // Maker interface for managing token type Maker interface { // CreateToken create token from username and duration - CreateToken(username string, duration time.Duration) (string, error) + CreateToken(username string, duration time.Duration) (string, *Payload, error) // VerifyToken checks if token is valid or not VerifyToken(token string) (*Payload, error) diff --git a/token/paseto_maker.go b/token/paseto_maker.go index 70bc59b..0d96198 100644 --- a/token/paseto_maker.go +++ b/token/paseto_maker.go @@ -29,13 +29,14 @@ func NewPasetoMaker(symmetricKey string) (Maker, error) { return maker, nil } -func (p PasetoMaker) CreateToken(username string, duration time.Duration) (string, error) { +func (p PasetoMaker) CreateToken(username string, duration time.Duration) (string, *Payload, error) { payload, err := NewPayload(username, duration) if err != nil { - return "", err + return "", nil, err } - return p.paseto.Encrypt(p.symmetricKey, payload, nil) + token, err := p.paseto.Encrypt(p.symmetricKey, payload, nil) + return token, payload, err } func (p PasetoMaker) VerifyToken(token string) (*Payload, error) { diff --git a/token/paseto_maker_test.go b/token/paseto_maker_test.go index 6d15931..40035d5 100644 --- a/token/paseto_maker_test.go +++ b/token/paseto_maker_test.go @@ -32,17 +32,14 @@ func TestPasetoMaker_Token(t *testing.T) { maker, err := NewPasetoMaker(secretKey) require.Nil(t, err) - token, err := maker.CreateToken(args.Username, duration) + token, returnedPayload, err := maker.CreateToken(args.Username, duration) require.Nil(t, err) require.NotEmpty(t, token) + verifyPayload(t, args, returnedPayload) payload, err := maker.VerifyToken(token) require.Nil(t, err) - - require.NotEmpty(t, payload) - require.Equal(t, args.Username, payload.Username) - require.WithinDuration(t, args.IssuedAt, payload.IssuedAt, time.Second) - require.WithinDuration(t, args.ExpiredAt, payload.ExpiredAt, time.Second) + verifyPayload(t, args, payload) }, }, { @@ -62,3 +59,10 @@ func TestPasetoMaker_Token(t *testing.T) { }) } } + +func verifyPayload(t *testing.T, want *Payload, have *Payload) { + require.NotEmpty(t, have) + require.Equal(t, want.Username, have.Username) + require.WithinDuration(t, want.IssuedAt, have.IssuedAt, time.Second) + require.WithinDuration(t, want.ExpiredAt, have.ExpiredAt, time.Second) +} diff --git a/utils/config.go b/utils/config.go index 7b486ea..a1633ae 100644 --- a/utils/config.go +++ b/utils/config.go @@ -6,11 +6,12 @@ import ( ) type Config struct { - DBDriver string `mapstructure:"DB_DRIVER"` - DBSource string `mapstructure:"DB_SOURCE"` - ServerAddress string `mapstructure:"SERVER_ADDRESS"` - TokenDuration time.Duration `mapstructure:"TOKEN_DURATION"` - TokenSymmetricKey string `mapstructure:"TOKEN_SYMMETRIC_KEY"` + DBDriver string `mapstructure:"DB_DRIVER"` + DBSource string `mapstructure:"DB_SOURCE"` + ServerAddress string `mapstructure:"SERVER_ADDRESS"` + TokenDuration time.Duration `mapstructure:"TOKEN_DURATION"` + RefreshTokenDuration time.Duration `mapstructure:"REFRESH_TOKEN_DURATION"` + TokenSymmetricKey string `mapstructure:"TOKEN_SYMMETRIC_KEY"` } func LoadConfig(path string) (config Config, err error) { From 67c708ad87ba81aca037c6d3491326c198b1c20d Mon Sep 17 00:00:00 2001 From: cukhoaimon Date: Sun, 4 Feb 2024 23:10:29 +0700 Subject: [PATCH 3/3] feat: add documentatio for db --- Makefile | 6 ++++- doc/database.dbml | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 doc/database.dbml diff --git a/Makefile b/Makefile index a49cc80..d581fd0 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,11 @@ test: server: go run main.go +dbdocs: + dbdocs build doc/database.dbml + + mock: mockgen -package mockdb -destination db/mock/store.go github.com/cukhoaimon/SimpleBank/db/sqlc Store -.PHONY: postgres createdb dropdb migrate-up migrate-down sqlc test server mock \ No newline at end of file +.PHONY: postgres createdb dropdb migrate-up migrate-down sqlc test server mock dbdocs \ No newline at end of file diff --git a/doc/database.dbml b/doc/database.dbml new file mode 100644 index 0000000..4320e8f --- /dev/null +++ b/doc/database.dbml @@ -0,0 +1,64 @@ +Project SimpleBank { + database_type: 'PostgreSQL' + Note: ''' + # SimpleBank database + ''' +} + +Table users as U { + username varchar [pk] + hashed_password varchar [not null] + full_name varchar [not null] + email varchar [unique, not null] + password_changed_at timestamptz [not null, default: '0001-01-01 00:00:00Z'] + created_at timestamptz [not null, default: `now()`] +} + +Table accounts as A { + id bigserial [pk] + owner varchar [ref: > U.username, not null] + balance bigint [not null] + currency varchar [not null] + created_at timestamptz [not null, default: `now()`] + + Indexes { + owner + (owner, currency) + } +} + +Table entries { + id bigserial [pk] + account_id bigint [ref: > A.id, not null] + amount bigint [not null, note: 'can be negative or positive'] + created_at timestamptz [not null, default: `now()`] + + Indexes { + account_id + } +} + +Table transfers { + id bigserial [pk] + from_account_id bigint [ref: > A.id, not null] + to_account_id bigint [ref: > A.id, not null] + amount bigint [not null, note: "must be positive"] + created_at timestamptz [not null, default: `now()`] + + Indexes { + from_account_id + to_account_id + (from_account_id, to_account_id) + } +} + +Table sessions { + id uuid [pk] + username varchar [ref: > U.username, not null] + refresh_token varchar [not null] + user_agent varchar [not null] + client_ip varchar [not null] + is_blocked boolean [not null, default: `false`] + expires_at timestamptz [not null] + created_at timestamptz [not null, default: `now()`] +} \ No newline at end of file