Skip to content

Commit

Permalink
Merge pull request techschool#10 from techschool/login_user
Browse files Browse the repository at this point in the history
Add API to login user and return access token
  • Loading branch information
techschool authored Feb 7, 2021
2 parents b909885 + 15aecd8 commit 9b50ce7
Show file tree
Hide file tree
Showing 18 changed files with 554 additions and 25 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ In this backend master class, we’re going to learn everything about how to des
- Lecture #16: [How to handle DB errors in Golang correctly](https://www.youtube.com/watch?v=mJ8b5GcvoxQ&list=PLy_6D98if3ULEtXtNSY_2qN21VCKgoQAE&index=16)
- Lecture #17: [How to securely store passwords? Hash password in Go with Bcrypt!](https://www.youtube.com/watch?v=B3xnJI2lHmc&list=PLy_6D98if3ULEtXtNSY_2qN21VCKgoQAE&index=17)
- Lecture #18: [How to write stronger unit tests with a custom gomock matcher](https://www.youtube.com/watch?v=DuzBE0jKOgE&list=PLy_6D98if3ULEtXtNSY_2qN21VCKgoQAE&index=18)
- Lecture #19: [Why PASETO is better than JWT for token-based authentication?](https://www.youtube.com/watch?v=nBGx-q52KAY&list=PLy_6D98if3ULEtXtNSY_2qN21VCKgoQAE&index=19)

## Simple bank service

Expand Down
6 changes: 3 additions & 3 deletions api/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func TestGetAccountAPI(t *testing.T) {
store := mockdb.NewMockStore(ctrl)
tc.buildStubs(store)

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

url := fmt.Sprintf("/accounts/%d", tc.accountID)
Expand Down Expand Up @@ -194,7 +194,7 @@ func TestCreateAccountAPI(t *testing.T) {
store := mockdb.NewMockStore(ctrl)
tc.buildStubs(store)

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

// Marshal body data to JSON
Expand Down Expand Up @@ -309,7 +309,7 @@ func TestListAccountsAPI(t *testing.T) {
store := mockdb.NewMockStore(ctrl)
tc.buildStubs(store)

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

url := "/accounts"
Expand Down
16 changes: 16 additions & 0 deletions api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,26 @@ package api
import (
"os"
"testing"
"time"

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

func newTestServer(t *testing.T, store db.Store) *Server {
config := util.Config{
TokenSymmetricKey: util.RandomString(32),
AccessTokenDuration: time.Minute,
}

server, err := NewServer(config, store)
require.NoError(t, err)

return server
}

func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)

Expand Down
33 changes: 27 additions & 6 deletions api/server.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,50 @@
package api

import (
"fmt"

"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
db "github.com/techschool/simplebank/db/sqlc"
"github.com/techschool/simplebank/token"
"github.com/techschool/simplebank/util"
)

// Server serves HTTP requests for our banking service.
type Server struct {
store db.Store
router *gin.Engine
config util.Config
store db.Store
tokenMaker token.Maker
router *gin.Engine
}

// NewServer creates a new HTTP server and set up routing.
func NewServer(store db.Store) *Server {
server := &Server{store: store}
router := gin.Default()
func NewServer(config util.Config, store db.Store) (*Server, error) {
tokenMaker, err := token.NewPasetoMaker(config.TokenSymmetricKey)
if err != nil {
return nil, fmt.Errorf("cannot create token maker: %w", err)
}

server := &Server{
config: config,
store: store,
tokenMaker: tokenMaker,
}

if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("currency", validCurrency)
}

server.setupRouter()
return server, nil
}

func (server *Server) setupRouter() {
router := gin.Default()

router.POST("/users", server.createUser)
router.POST("/users/login", server.loginUser)

router.POST("/accounts", server.createAccount)
router.GET("/accounts/:id", server.getAccount)
Expand All @@ -31,7 +53,6 @@ func NewServer(store db.Store) *Server {
router.POST("/transfers", server.createTransfer)

server.router = router
return server
}

// Start runs the HTTP server on a specific address.
Expand Down
2 changes: 1 addition & 1 deletion api/transfer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ func TestTransferAPI(t *testing.T) {
store := mockdb.NewMockStore(ctrl)
tc.buildStubs(store)

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

// Marshal body data to JSON
Expand Down
68 changes: 61 additions & 7 deletions api/user.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

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

Expand All @@ -17,14 +18,24 @@ type createUserRequest struct {
Email string `json:"email" binding:"required,email"`
}

type createUserResponse struct {
type userResponse struct {
Username string `json:"username"`
FullName string `json:"full_name"`
Email string `json:"email"`
PasswordChangedAt time.Time `json:"password_changed_at"`
CreatedAt time.Time `json:"created_at"`
}

func newUserResponse(user db.User) userResponse {
return userResponse{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
PasswordChangedAt: user.PasswordChangedAt,
CreatedAt: user.CreatedAt,
}
}

func (server *Server) createUser(ctx *gin.Context) {
var req createUserRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
Expand Down Expand Up @@ -58,12 +69,55 @@ func (server *Server) createUser(ctx *gin.Context) {
return
}

rsp := createUserResponse{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
PasswordChangedAt: user.PasswordChangedAt,
CreatedAt: user.CreatedAt,
rsp := newUserResponse(user)
ctx.JSON(http.StatusOK, rsp)
}

type loginUserRequest struct {
Username string `json:"username" binding:"required,alphanum"`
Password string `json:"password" binding:"required,min=6"`
}

type loginUserResponse struct {
AccessToken string `json:"access_token"`
User userResponse `json:"user"`
}

func (server *Server) loginUser(ctx *gin.Context) {
var req loginUserRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return
}

user, err := server.store.GetUser(ctx, req.Username)
if err != nil {
if err == sql.ErrNoRows {
ctx.JSON(http.StatusNotFound, errorResponse(err))
return
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}

err = util.CheckPassword(req.Password, user.HashedPassword)
if err != nil {
ctx.JSON(http.StatusUnauthorized, errorResponse(err))
return
}

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

rsp := loginUserResponse{
AccessToken: accessToken,
User: newUserResponse(user),
}
ctx.JSON(http.StatusOK, rsp)
}
121 changes: 120 additions & 1 deletion api/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func TestCreateUserAPI(t *testing.T) {
store := mockdb.NewMockStore(ctrl)
tc.buildStubs(store)

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

// Marshal body data to JSON
Expand All @@ -197,6 +197,125 @@ func TestCreateUserAPI(t *testing.T) {
}
}

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)
},
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,
"full_name": user.FullName,
"email": user.Email,
},
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
2 changes: 2 additions & 0 deletions app.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,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
ACCESS_TOKEN_DURATION=15m
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ module github.com/techschool/simplebank
go 1.15

require (
github.com/aead/chacha20poly1305 v0.0.0-20201124145622-1a5aba2a8b29
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/gin-gonic/gin v1.6.3
github.com/go-playground/validator/v10 v10.4.1
github.com/golang/mock v1.4.4
github.com/google/uuid v1.1.4
github.com/lib/pq v1.9.0
github.com/o1egl/paseto v1.0.0
github.com/spf13/viper v1.7.1
github.com/stretchr/testify v1.6.1
golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
)
Loading

0 comments on commit 9b50ce7

Please sign in to comment.