Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.log
23 changes: 17 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
DB_URL=postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable

postgres:
docker run --name postgres12 --network bank-network -p 5432:5432 -e POSTGRES_USER=root -e POSTGRES_PASSWORD=secret -d postgres:12-alpine

Expand All @@ -8,19 +10,22 @@ dropdb:
docker exec -it postgres12 dropdb simple_bank

migrateup:
migrate -path db/migration -database "postgresql://root:lk515405@simple-bank.cwisktgnesvo.ap-east-1.rds.amazonaws.com:5432/simple_bank" -verbose up
migrate -path db/migration -database "$(DB_URL)" -verbose up

migrateup1:
migrate -path db/migration -database "postgresql://root:lk515405@simple-bank.cwisktgnesvo.ap-east-1.rds.amazonaws.com:5432/simple_bank" -verbose up 1
migrate -path db/migration -database "$(DB_URL)" -verbose up 1

migratedown:
migrate -path db/migration -database "postgresql://root:lk515405@simple-bank.cwisktgnesvo.ap-east-1.rds.amazonaws.com:5432/simple_bank" -verbose down
migrate -path db/migration -database "$(DB_URL)" -verbose down

migratedown1:
migrate -path db/migration -database "postgresql://root:lk515405@simple-bank.cwisktgnesvo.ap-east-1.rds.amazonaws.com:5432/simple_bank" -verbose down 1
migrate -path db/migration -database "$(DB_URL)" -verbose down 1

localmigrateup:
migrate -path db/migration -database "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable" -verbose up
migrate -path db/migration -database "$(DB_URL)" -verbose up

localmigratedown:
migrate -path db/migration -database "$(DB_URL)" -verbose down

# migrateup1:
# migrate -path db/migration -database "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable" -verbose up 1
Expand Down Expand Up @@ -55,4 +60,10 @@ server:
mock:
mockgen -source=./db/sqlc/store.go -destination=./db/mock/store.go -package=mockdb

.PHONY: postgres createdb dropdb migrateup migrateup1 migratedown migratedown1 awsmigratedown awsmigratedown1 awsmigrateup awsmigrateup1 sqlc test server mock localmigrateup
db_docs:
dbdocs build doc/db.dbml

db_schema:
dbml2sql --postgres -o doc/schema.sql doc/db.dbml

.PHONY: postgres createdb dropdb migrateup migrateup1 migratedown migratedown1 awsmigratedown awsmigratedown1 awsmigrateup awsmigrateup1 sqlc test server mock localmigrateup localmigratedown db_docs db_schema
1 change: 1 addition & 0 deletions api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"github.com/gin-gonic/gin"

"github.com/stretchr/testify/require"
db "github.com/techschool/simplebank/db/sqlc"
"github.com/techschool/simplebank/util"
Expand Down
3 changes: 2 additions & 1 deletion api/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ func addAuthorizatin(
username string,
duration time.Duration,
) {
token, err := tokenMaker.CreateToken(username, duration)
token, claims, err := tokenMaker.CreateToken(username, duration)
require.NotEmpty(t, claims)
require.NoError(t, err)

authorizationHeader := fmt.Sprintf("%s %s", authorizationType, token)
Expand Down
1 change: 1 addition & 0 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func (server *Server) setupRouter() {
router := gin.Default()
router.POST("/users", server.createUser)
router.POST("/users/login", server.loginUser)
router.POST("/tokens/renew_access", server.renewAccessToken)

authRoutes := router.Group("/").Use(authMiddleware(server.tokenMaker))

Expand Down
83 changes: 83 additions & 0 deletions api/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package api

import (
"database/sql"
"fmt"
"net/http"
"time"

"github.com/gin-gonic/gin"
)

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) renewAccessToken(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)
if err != nil {
ctx.JSON(http.StatusUnauthorized, errorResponse(err))
return
}

session, err := server.store.GetSession(ctx, refreshPayload.UUID)
if err != nil {
if err == sql.ErrNoRows {
ctx.JSON(http.StatusNotFound, errorResponse(err))
return
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}

if session.IsBlocked {
err := fmt.Errorf("block session")
ctx.JSON(http.StatusUnauthorized, errorResponse(err))
return
}

if session.Username != refreshPayload.Username {
err := fmt.Errorf("invalid session user")
ctx.JSON(http.StatusUnauthorized, errorResponse(err))
return
}

if session.RefreshToken != req.RefreshToken {
err := fmt.Errorf("mismatched session token")
ctx.JSON(http.StatusUnauthorized, errorResponse(err))
return
}

if time.Now().After(session.ExpiresAt) {
err := fmt.Errorf("expired session")
ctx.JSON(http.StatusUnauthorized, errorResponse(err))
return
}

accesToken, accessPayload, err := server.tokenMaker.CreateToken(
refreshPayload.Username,
server.config.AccessTokenDuration,
)
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}

rsp := renewAccessTokenResponse{
AccessToken: accesToken,
AccessTokenExpiresAt: accessPayload.ExpiresAt.Time,
}

ctx.JSON(http.StatusOK, rsp)
}
41 changes: 36 additions & 5 deletions api/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/lib/pq"
db "github.com/techschool/simplebank/db/sqlc"
"github.com/techschool/simplebank/util"
Expand Down Expand Up @@ -80,8 +81,12 @@ type loginUserRequest struct {
}

type loginUserResponse 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) {
Expand All @@ -107,7 +112,7 @@ func (server *Server) loginUser(ctx *gin.Context) {
return
}

accesToken, err := server.tokenMaker.CreateToken(
accesToken, accessPayload, err := server.tokenMaker.CreateToken(
user.Username,
server.config.AccessTokenDuration,
)
Expand All @@ -116,9 +121,35 @@ func (server *Server) loginUser(ctx *gin.Context) {
return
}

refreshToken, refreshPayload, err := server.tokenMaker.CreateToken(
user.Username,
server.config.RefreshTokenDuration,
)
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
session, err := server.store.CreateSession(ctx, db.CreateSessionParams{
ID: refreshPayload.UUID,
Username: user.Username,
RefreshToken: refreshToken,
UserAgent: ctx.Request.UserAgent(),
ClientIp: ctx.ClientIP(),
IsBlocked: false,
ExpiresAt: refreshPayload.ExpiresAt.Time,
})
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}

rsp := loginUserResponse{
AccessToken: accesToken,
User: newUserResponse(user),
SessionID: session.ID,
AccessToken: accesToken,
AccessTokenExpiresAt: accessPayload.ExpiresAt.Time,
RefreshToken: refreshToken,
RefreshTokenExpiresAt: refreshPayload.ExpiresAt.Time,
User: newUserResponse(user),
}

ctx.JSON(http.StatusOK, rsp)
Expand Down
128 changes: 128 additions & 0 deletions api/user_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,141 @@
package api

import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
mockdb "github.com/techschool/simplebank/db/mock"
db "github.com/techschool/simplebank/db/sqlc"
"github.com/techschool/simplebank/util"
)

func TestLoginUserAPI(t *testing.T) {
user, password := randomUser(t)

testCases := []struct {
name string
body gin.H
buildStubs func(store *mockdb.MockStore)
checkResponse func(recoder *httptest.ResponseRecorder)
}{
{
name: "OK",
body: gin.H{
"username": user.Username,
"password": password,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetUser(gomock.Any(), gomock.Eq(user.Username)).
Times(1).
Return(user, nil)
store.EXPECT().
CreateSession(gomock.Any(), gomock.Any()).
Times(1)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusOK, recorder.Code)
},
},
{
name: "UserNotFound",
body: gin.H{
"username": "NotFound",
"password": password,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetUser(gomock.Any(), gomock.Any()).
Times(1).
Return(db.User{}, sql.ErrNoRows)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusNotFound, recorder.Code)
},
},
{
name: "IncorrectPassword",
body: gin.H{
"username": user.Username,
"password": "incorrect",
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetUser(gomock.Any(), gomock.Eq(user.Username)).
Times(1).
Return(user, nil)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
name: "InternalError",
body: gin.H{
"username": user.Username,
"password": password,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetUser(gomock.Any(), gomock.Any()).
Times(1).
Return(db.User{}, sql.ErrConnDone)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusInternalServerError, recorder.Code)
},
},
{
name: "InvalidUsername",
body: gin.H{
"username": "invalid-user#1",
"password": password,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
GetUser(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
}

for i := range testCases {
tc := testCases[i]

t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

store := mockdb.NewMockStore(ctrl)
tc.buildStubs(store)

server := NewTestServer(t, store)
recorder := httptest.NewRecorder()

// Marshal body data to JSON
data, err := json.Marshal(tc.body)
require.NoError(t, err)

url := "/users/login"
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
require.NoError(t, err)

server.router.ServeHTTP(recorder, request)
tc.checkResponse(recorder)
})
}
}

func randomUser(t *testing.T) (user db.User, password string) {
password = util.RandomString(6)
hashedPassword, err := util.HashPassword(password)
Expand Down
1 change: 1 addition & 0 deletions app.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ DB_DRIVER=postgres
SERVER_ADDRESS=0.0.0.0:8080
ACCESS_TOKEN_DURATION=15m
TOKEN_SYMMETRIC_KEY=12345678901234567890123456789012
REFRESH_TOKEN_DURATION=24h
1 change: 1 addition & 0 deletions db/migration/000003_add_sessions.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS "sessions";
12 changes: 12 additions & 0 deletions db/migration/000003_add_sessions.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
CREATE TABLE "sessions" (
"id" UUID PRIMARY KEY,
"username" varchar 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())
);

ALTER TABLE "sessions" ADD FOREIGN KEY ("username") REFERENCES "users" ("username");
Loading