Skip to content

Commit

Permalink
Implement TOTP as option an
Browse files Browse the repository at this point in the history
for two factor authentication

Signed-off-by: William <[email protected]>
  • Loading branch information
kwesidev committed Sep 20, 2023
1 parent fb8955d commit f425f0e
Show file tree
Hide file tree
Showing 14 changed files with 293 additions and 24 deletions.
19 changes: 10 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
PG_HOST=localhost
PG_USER=
PG_PASSWORD=
PG_DB=apiauth
PG_PORT=
PG_USER=postgres
PG_PASSWORD=postgres
PG_DB=auth_server
PG_PORT=5432
PG_CERT=
PG_SSL=True
JWT_SECRET=
SMTP_HOST=
SMTP_PORT=25
PG_SSL=False
JWT_SECRET=Jwttestt1111
SMTP_HOST=mailhog
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
FROM_EMAIL_ADDRESS=
FROM_EMAIL_ADDRESS=test@localhost
SERVER_ADDRESS=
SERVER_PORT=8080
TOKEN_EXPIRY_TIME=15m
ISSUER_NAME=
9 changes: 9 additions & 0 deletions db/migrations/000002_add_twofactor_options.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
BEGIN;
ALTER TABLE users DROP COLUMN two_factor_type;
ALTER TABLE users DROP COLUMN totp_secret;
ALTER TABLE users DROP COLUMN totp_created;
ALTER TABLE users DROP COLUMN totp_url;
ALTER TABLE two_factor_requests DROP COLUMN send_type;
DROP TYPE two_factor_type;
DROP TYPE send_type;
COMMIT;
20 changes: 20 additions & 0 deletions db/migrations/000002_add_twofactor_options.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
BEGIN;
-- CREATE TYPES
CREATE TYPE enum_two_factor_type AS ENUM ('SMS','EMAIL','TOTP','NONE');
CREATE TYPE enum_send_type AS ENUM ('SMS','EMAIL');
-- CREATE new columns
ALTER TABLE users ADD COLUMN two_factor_type enum_two_factor_type;
-- Update to email
UPDATE users SET two_factor_type = 'EMAIL' WHERE two_factor_enabled = True;
UPDATE users SET two_factor_type = 'NONE' WHERE two_factor_enabled = False;
-- Add hotop columns
ALTER TABLE users ADD COLUMN totp_secret VARCHAR ;
UPDATE users SET totp_secret = '';
ALTER TABLE users ADD COLUMN totp_url VARCHAR ;
UPDATE users SET totp_url = '';
ALTER TABLE users ADD COLUMN totp_created TIMESTAMP ;
-- Update previous records send type to email since there was no other option except email
ALTER TABLE two_factor_requests ADD COLUMN send_type enum_send_type NOT NULL;
UPDATE two_factor_requests SET send_type = 'EMAIL';

COMMIT;
7 changes: 5 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ go 1.19

require (
github.com/go-playground/validator/v10 v10.12.0
github.com/golang-migrate/migrate/v4 v4.16.2
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.7
github.com/pquerna/otp v1.4.0
golang.org/x/crypto v0.7.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
)

require gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
)

require (
github.com/go-playground/locales v0.14.1 // indirect
Expand Down
10 changes: 5 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand All @@ -10,10 +12,6 @@ github.com/go-playground/validator/v10 v10.12.0 h1:E4gtWgxWxp8YSxExrQFv5BpCahla0
github.com/go-playground/validator/v10 v10.12.0/go.mod h1:hCAPuzYvKdP33pxWa+2+6AIKXEKqjIUyqsNCtbsSJrA=
github.com/golang-jwt/jwt/v5 v5.0.0-rc.2 h1:hXPcSazn8wKOfSb9y2m1bdgUMlDxVDarxh3lJVbC6JE=
github.com/golang-jwt/jwt/v5 v5.0.0-rc.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4=
Expand All @@ -22,15 +20,17 @@ github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rwtodd/Go.Sed v0.0.0-20210816025313-55464686f9ef/go.mod h1:8AEUvGVi2uQ5b24BIhcr0GCcpd/RNAFWaN2CJFrWIIQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
Expand Down
4 changes: 4 additions & 0 deletions internal/apiserver/api_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func (ap *APIServer) registerGlobalFunctions() {
http.HandleFunc("/api/auth/passwordResetRequest", middlewares.Method("POST", authController.PasswordResetRequest))
http.HandleFunc("/api/auth/verifyAndResetPassword", middlewares.Method("POST", authController.VerifyAndChangePassword))
http.HandleFunc("/api/auth/verifyTwoFactor", middlewares.Method("POST", authController.ValidateTwoFactor))
http.HandleFunc("/api/auth/verifyTOTP", middlewares.Method("POST", authController.VerifyTOTP))
http.HandleFunc("/health", authController.Health)
}

Expand All @@ -61,6 +62,9 @@ func (ap *APIServer) registerUserFunctions() {
http.HandleFunc("/api/user", middlewares.Method("GET", middlewares.JwtAuth(userController.Index)))
http.HandleFunc("/api/user/logout", middlewares.Method("POST", middlewares.JwtAuth(userController.Logout)))
http.HandleFunc("/api/user/update", middlewares.Method("POST", middlewares.JwtAuth(userController.Update)))
http.HandleFunc("/api/user/enableTwoFactor", middlewares.Method("POST", middlewares.JwtAuth(userController.EnableTwoFactor)))
http.HandleFunc("/api/user/verifyPassCode", middlewares.Method("POST", middlewares.JwtAuth(userController.VerifyPassCode)))

}

// register admin functions
Expand Down
29 changes: 29 additions & 0 deletions internal/controllers/auth_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,35 @@ func (authCtrl *AuthController) ValidateTwoFactor(w http.ResponseWriter, r *http
return
}
utilities.JSONResponse(w, authResult)
}

// Validates Two Factor authCtrl function is only called when two factor is required
func (authCtrl *AuthController) VerifyTOTP(w http.ResponseWriter, r *http.Request) {
totpValidateRequest := models.VerifyHOTPRequest{}
err := utilities.GetJsonInput(&totpValidateRequest, r)
if err != nil {
utilities.JSONError(w, err.Error(), http.StatusBadRequest)
return
}
// Validates requests
err = authCtrl.validate.Struct(totpValidateRequest)
if err != nil {
log.Println(err)
utilities.JSONError(w, err.Error(), http.StatusBadRequest)
return
}
userDataToken, err := utilities.ValidateJwtAndGetClaims(totpValidateRequest.Token)
if err != nil {
utilities.JSONError(w, "Invalid Token", http.StatusBadRequest)
return
}
userId := userDataToken["userId"].(int)
authResult, err := authCtrl.authService.VerifyTOTP(userId, totpValidateRequest.Code, "", "")
if err != nil {
utilities.JSONError(w, err.Error(), http.StatusBadRequest)
return
}
utilities.JSONResponse(w, authResult)

}

Expand Down
63 changes: 63 additions & 0 deletions internal/controllers/user_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type UserController struct {
// Registered Services
db *sql.DB
userService services.UserService
authService services.AuthService
validate *validator.Validate
}

Expand All @@ -25,6 +26,7 @@ func NewUserController(db *sql.DB) *UserController {
return &UserController{
db: db,
userService: *services.NewUserService(db),
authService: *services.NewAuthService(db),
validate: validator.New(),
}
}
Expand Down Expand Up @@ -89,3 +91,64 @@ func (usrCtrl *UserController) Logout(w http.ResponseWriter, r *http.Request) {
response.Success = success
utilities.JSONResponse(w, response)
}

// Enable Time based OTP
func (usrCtrl *UserController) EnableTwoFactor(w http.ResponseWriter, r *http.Request) {
enableTwoFactorRequest := models.EnableTwoFactorRequest{}
err := utilities.GetJsonInput(&enableTwoFactorRequest, r)
if err != nil {
utilities.JSONError(w, err.Error(), http.StatusBadRequest)
return
}
claims := r.Context().Value("claims").(map[string]interface{})
userId := claims["userId"].(int)
if enableTwoFactorRequest.Type == "TOTP" {
totpResponse, err := usrCtrl.userService.EnableTwoFactorTOTP(userId)
if err != nil {
utilities.JSONError(w, "Failed to Enable Two Factor (TOTP)", http.StatusBadRequest)
return
}
utilities.JSONResponse(w, totpResponse)
return
} else {
err := usrCtrl.userService.EnableTwoFactor(userId, enableTwoFactorRequest.Type)
if err != nil {
utilities.JSONError(w, "Failed to Enabled Two Factor EMAIL OR SMS ", http.StatusBadRequest)
return
}
}
response := struct {
Success bool `json:"success"`
}{}
response.Success = true
utilities.JSONResponse(w, response)
}

// Enable Time based OTP
func (usrCtrl *UserController) VerifyPassCode(w http.ResponseWriter, r *http.Request) {
verifyPassCodeRequest := models.VerifyPassCodeRequest{}
err := utilities.GetJsonInput(&verifyPassCodeRequest, r)
if err != nil {
utilities.JSONError(w, err.Error(), http.StatusBadRequest)
return
}
// Validates requests
err = usrCtrl.validate.Struct(verifyPassCodeRequest)
if err != nil {
log.Println(err)
utilities.JSONError(w, err.Error(), http.StatusBadRequest)
return
}
claims := r.Context().Value("claims").(map[string]interface{})
userId := claims["userId"].(int)
response := struct {
Success bool `json:"success"`
}{}
if usrCtrl.authService.VerifyPassCode(userId, verifyPassCodeRequest.Code) {
response.Success = true
} else {
response.Success = false
}
utilities.JSONResponse(w, response)

}
6 changes: 6 additions & 0 deletions internal/models/auth_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ type AuthenticationResponse struct {
Roles []string `json:"roles,omitempty"`
Expires int `json:"expiresIn,omitempty"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TwoFactorType string `json:"twoFactorType,omitempty"`
}

type VerifyHOTPRequest struct {
Token string `json:"token"`
Code string `json:"code"`
}

type VerifyTwoFactorRequest struct {
Expand Down
13 changes: 12 additions & 1 deletion internal/models/user_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,19 @@ type User struct {
Roles []string `json:"roles"`
Active bool `json:"active"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TwoFactorType string `json:"twoFactorType"`
TOTPSecret string
TOTPURL string
}
type EnableTwoFactorRequest struct {
Type string `json:"type"`
}
type EnableTOTPResponse struct {
URL string `json:"url"`
}
type VerifyPassCodeRequest struct {
Code string `json:"code"`
}

type UserRegistrationRequest struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Expand Down
36 changes: 33 additions & 3 deletions internal/services/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/kwesidev/authserver/internal/models"
"github.com/kwesidev/authserver/internal/utilities"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)

Expand Down Expand Up @@ -52,7 +53,17 @@ func (authSrv *AuthService) Login(username, password, ipAddress, userAgent strin
}
// Check if two authentication is required
if userDetails.TwoFactorEnabled {
return authSrv.twoFactorRequest(*userDetails, ipAddress, userAgent)
if userDetails.TwoFactorType != "TOTP" {
return authSrv.twoFactorRequest(*userDetails, ipAddress, userAgent)
}
// Otherwise its TOTP then
authResult := &models.AuthenticationResponse{}
// Generate a short token which expires after 5minutes
shortToken, _ := utilities.GenerateJwtToken(userId, userDetails.Roles, (time.Second * 300))
authResult.TwoFactorEnabled = true
authResult.Token = shortToken
authResult.TwoFactorType = userDetails.TwoFactorType
return authResult, nil
}
// Get user roles
roles, err := authSrv.userService.GetRoles(userId)
Expand Down Expand Up @@ -217,9 +228,9 @@ func (authSrv *AuthService) twoFactorRequest(userDetails models.User, ipAddress
queryString :=
`INSERT
INTO two_factor_requests
(user_id, request_id, ip_address, code, user_agent, created_at, expiry_time)
(user_id, request_id, ip_address, code, user_agent, created_at, send_type, expiry_time)
VALUES
($1, $2 ,$3 ,$4, $5, NOW(), $6)
($1, $2 ,$3 ,$4, $5, NOW(),'EMAIL', $6)
`
if _, err = tx.Exec(queryString, userDetails.ID, requestId, ipAddress, randomCodes, userAgent, time.Now().Add(expires)); err != nil {
log.Println(err)
Expand All @@ -234,6 +245,7 @@ func (authSrv *AuthService) twoFactorRequest(userDetails models.User, ipAddress
}
authResult.TwoFactorEnabled = true
authResult.Token = requestId
authResult.TwoFactorType = userDetails.TwoFactorType
return authResult, nil
}

Expand Down Expand Up @@ -301,3 +313,21 @@ func (authSrv *AuthService) DeleteExpiredTokens(days int) error {
log.Println("DELETED number of rows for reset_password_requests tokens :", count)
return err
}

// Verify the passcode
func (authSrv *AuthService) VerifyPassCode(userId int, passCode string) bool {
userDetails := authSrv.userService.Get(userId)
if valid := totp.Validate(passCode, userDetails.TOTPSecret); !valid {
return false
}
return true
}

// Validates the TOTP before the user finally logs in
func (authSrv *AuthService) VerifyTOTP(userId int, passCode, ipAddress, userAgent string) (*models.AuthenticationResponse, error) {
userDetails := authSrv.userService.Get(userId)
if authSrv.VerifyPassCode(userId, passCode) {
return nil, ErrorPassCode
}
return authSrv.generateTokenDetails(*userDetails, ipAddress, userAgent)
}
28 changes: 28 additions & 0 deletions internal/services/auth_service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package services

import (
"testing"

"github.com/kwesidev/authserver/internal/utilities"
_ "github.com/lib/pq"
)

func TestValidLogin(t *testing.T) {
db, err := utilities.GetMainDatabaseConnection(utilities.DatabaseConfig{
Host: "localhost",
Userame: "postgres",
Password: "root",
Database: "apiauth",
Port: "5432",
})
if err != nil {
t.Error("Connection to database failed: ", err)
return
}
authService := NewAuthService(db)
_, err = authService.Login("jackie", "password", "", "")

if err != nil {
t.Error("Failed to authenticate")
}
}
1 change: 1 addition & 0 deletions internal/services/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ var (
ErrorTwoFactorRequest = errors.New("Failed to Send Two Factor Request")
ErrorInvalidCode = errors.New("Code is invalid")
ErrorServer = errors.New("Server Error, Try again later")
ErrorPassCode = errors.New("Invalid Passcode")
)
Loading

0 comments on commit f425f0e

Please sign in to comment.