Skip to content

Commit cf42161

Browse files
authored
Merge pull request #105 from PUFA-Computing/feat/2fa
feat: 2 Factor Authentication
2 parents 5b8aba1 + 73a9b21 commit cf42161

File tree

11 files changed

+352
-19
lines changed

11 files changed

+352
-19
lines changed

api/routes.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ func SetupRoutes() *gin.Engine {
8585
userRoutes.PUT("/edit", userHandlers.EditUser)
8686
userRoutes.DELETE("/delete", userHandlers.DeleteUser)
8787
userRoutes.POST("/upload-profile-picture", userHandlers.UploadProfilePicture)
88-
userRoutes.PUT("/:userID/update-user", userHandlers.AdminUpdateRoleAndStudentIDVerified)
88+
userRoutes.PUT("/update-user", userHandlers.AdminUpdateRoleAndStudentIDVerified)
89+
userRoutes.POST("/2fa/enable", userHandlers.EnableTwoFA)
90+
userRoutes.POST("/2fa/verify", userHandlers.VerifyTwoFA)
91+
userRoutes.POST("/2fa/disable", userHandlers.DisableTwoFA)
8992

9093
// ListEventsRegisteredByUser
9194
userRoutes.GET("/registered-events", eventHandlers.ListEventsRegisteredByUser)

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ require (
3838
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
3939
github.com/aws/smithy-go v1.19.0 // indirect
4040
github.com/benbjohnson/clock v1.3.0 // indirect
41+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
4142
github.com/bytedance/sonic v1.10.1 // indirect
4243
github.com/cespare/xxhash/v2 v2.2.0 // indirect
4344
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
@@ -69,6 +70,7 @@ require (
6970
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
7071
github.com/pkg/errors v0.9.1 // indirect
7172
github.com/pmezard/go-difflib v1.0.0 // indirect
73+
github.com/pquerna/otp v1.4.0 // indirect
7274
github.com/rogpeppe/go-internal v1.11.0 // indirect
7375
github.com/stretchr/objx v0.5.0 // indirect
7476
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
4444
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
4545
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
4646
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
47+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
48+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
4749
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
4850
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
4951
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -183,6 +185,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
183185
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
184186
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
185187
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
188+
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
189+
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
186190
github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0=
187191
github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
188192
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=

internal/database/app/user.go

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"errors"
99
"github.com/google/uuid"
1010
"log"
11+
"strconv"
12+
"strings"
1113
)
1214

1315
func GetUserByUsernameOrEmail(username string) (*models.User, error) {
@@ -83,7 +85,7 @@ func GetUserByEmail(email string) (*models.User, error) {
8385
err := database.DB.QueryRow(context.Background(), query, email).Scan(
8486
&userID, &user.Username, &user.Password, &user.FirstName, &user.MiddleName, &user.LastName, &user.Email,
8587
&user.StudentID, &user.Major, &user.ProfilePicture, &user.DateOfBirth, &user.RoleID, &user.CreatedAt,
86-
&user.UpdatedAt, &user.Year, &user.InstitutionName, &user.Gender)
88+
&user.UpdatedAt, &user.Year, &user.InstitutionName, &user.Gender, &user.TwoFAEnabled, &user.TwoFAImage, &user.TwoFASecret)
8789
if err != nil {
8890
if errors.Is(err, sql.ErrNoRows) {
8991
return nil, nil // User not found, return nil
@@ -101,13 +103,16 @@ func GetUserByID(userID uuid.UUID) (*models.User, error) {
101103
var user models.User
102104

103105
err := database.DB.QueryRow(context.Background(), `
104-
SELECT id, username, password, first_name, middle_name, last_name, email, student_id, major, profile_picture, date_of_birth, role_id, created_at, updated_at, year, institution_name, gender
106+
SELECT id, username, password, first_name, middle_name, last_name, email, student_id, major, profile_picture, date_of_birth, role_id, created_at, updated_at, year, institution_name, gender, twofa_enabled, twofa_image, twofa_secret
105107
FROM users WHERE id = $1`, userID).Scan(
106108
&user.ID, &user.Username, &user.Password, &user.FirstName, &user.MiddleName, &user.LastName, &user.Email,
107109
&user.StudentID, &user.Major, &user.ProfilePicture, &user.DateOfBirth, &user.RoleID, &user.CreatedAt,
108-
&user.UpdatedAt, &user.Year, &user.InstitutionName, &user.Gender)
110+
&user.UpdatedAt, &user.Year, &user.InstitutionName, &user.Gender, &user.TwoFAEnabled, &user.TwoFAImage, &user.TwoFASecret)
109111

110112
if err != nil {
113+
if errors.Is(err, sql.ErrNoRows) {
114+
return nil, nil
115+
}
111116
return nil, err
112117
}
113118

@@ -153,13 +158,104 @@ func CheckStudentIDExists(studentID string) (bool, error) {
153158
}
154159

155160
func UpdateUser(UserID uuid.UUID, updatedUser *models.User) error {
156-
_, err := database.DB.Exec(context.Background(), `
157-
UPDATE users SET username = $1, password = $2, first_name = $3, middle_name = $4, last_name = $5, email = $6,
158-
student_id = $7, major = $8, year = $9, role_id = $10, updated_at = $11, institution_name= $12, gender = $13
159-
WHERE id = $14`,
160-
updatedUser.Username, updatedUser.Password, updatedUser.FirstName, updatedUser.MiddleName, updatedUser.LastName,
161-
updatedUser.Email, updatedUser.StudentID, updatedUser.Major, updatedUser.Year, updatedUser.RoleID,
162-
updatedUser.UpdatedAt, updatedUser.InstitutionName, updatedUser.Gender, UserID)
161+
query := "UPDATE users SET "
162+
args := []interface{}{}
163+
argID := 1
164+
165+
if updatedUser.Username != "" {
166+
query += "username = $" + strconv.Itoa(argID) + ", "
167+
args = append(args, updatedUser.Username)
168+
argID++
169+
}
170+
if updatedUser.Password != "" {
171+
query += "password = $" + strconv.Itoa(argID) + ", "
172+
args = append(args, updatedUser.Password)
173+
argID++
174+
}
175+
if updatedUser.FirstName != "" {
176+
query += "first_name = $" + strconv.Itoa(argID) + ", "
177+
args = append(args, updatedUser.FirstName)
178+
argID++
179+
}
180+
if updatedUser.MiddleName != nil && *updatedUser.MiddleName != "" {
181+
query += "middle_name = $" + strconv.Itoa(argID) + ", "
182+
args = append(args, updatedUser.MiddleName)
183+
argID++
184+
}
185+
if updatedUser.LastName != "" {
186+
query += "last_name = $" + strconv.Itoa(argID) + ", "
187+
args = append(args, updatedUser.LastName)
188+
argID++
189+
}
190+
if updatedUser.Email != "" {
191+
query += "email = $" + strconv.Itoa(argID) + ", "
192+
args = append(args, updatedUser.Email)
193+
argID++
194+
}
195+
if updatedUser.StudentID != "" {
196+
query += "student_id = $" + strconv.Itoa(argID) + ", "
197+
args = append(args, updatedUser.StudentID)
198+
argID++
199+
}
200+
if updatedUser.Major != "" {
201+
query += "major = $" + strconv.Itoa(argID) + ", "
202+
args = append(args, updatedUser.Major)
203+
argID++
204+
}
205+
if updatedUser.Year != "" {
206+
query += "year = $" + strconv.Itoa(argID) + ", "
207+
args = append(args, updatedUser.Year)
208+
argID++
209+
}
210+
if updatedUser.DateOfBirth != nil {
211+
query += "date_of_birth = $" + strconv.Itoa(argID) + ", "
212+
args = append(args, updatedUser.DateOfBirth)
213+
argID++
214+
}
215+
if updatedUser.RoleID != 0 {
216+
query += "role_id = $" + strconv.Itoa(argID) + ", "
217+
args = append(args, updatedUser.RoleID)
218+
argID++
219+
}
220+
if !updatedUser.UpdatedAt.IsZero() {
221+
query += "updated_at = $" + strconv.Itoa(argID) + ", "
222+
args = append(args, updatedUser.UpdatedAt)
223+
argID++
224+
}
225+
if updatedUser.InstitutionName != nil && *updatedUser.InstitutionName != "" {
226+
query += "institution_name = $" + strconv.Itoa(argID) + ", "
227+
args = append(args, updatedUser.InstitutionName)
228+
argID++
229+
}
230+
if updatedUser.Gender != "" {
231+
query += "gender = $" + strconv.Itoa(argID) + ", "
232+
args = append(args, updatedUser.Gender)
233+
argID++
234+
}
235+
if updatedUser.TwoFAEnabled != false {
236+
query += "twofa_enabled = $" + strconv.Itoa(argID) + ", "
237+
args = append(args, updatedUser.TwoFAEnabled)
238+
argID++
239+
}
240+
if updatedUser.TwoFAImage != nil && *updatedUser.TwoFAImage != "" {
241+
query += "twofa_image = $" + strconv.Itoa(argID) + ", "
242+
args = append(args, updatedUser.TwoFAImage)
243+
argID++
244+
}
245+
if updatedUser.TwoFASecret != nil && *updatedUser.TwoFASecret != "" {
246+
query += "twofa_secret = $" + strconv.Itoa(argID) + ", "
247+
args = append(args, updatedUser.TwoFASecret)
248+
argID++
249+
}
250+
251+
// Remove the last comma and space
252+
query = strings.TrimSuffix(query, ", ")
253+
254+
// Add the WHERE clause
255+
query += " WHERE id = $" + strconv.Itoa(argID)
256+
args = append(args, UserID)
257+
258+
_, err := database.DB.Exec(context.Background(), query, args...)
163259
return err
164260
}
165261

@@ -195,6 +291,7 @@ func ListUsers() ([]models.User, error) {
195291
&user.RoleID, &user.CreatedAt, &user.UpdatedAt, &user.Year, &user.EmailVerified,
196292
&user.EmailVerificationToken, &user.PasswordResetToken, &user.PasswordResetExpires,
197293
&user.StudentIDVerified, &user.StudentIDVerification, &user.InstitutionName, &user.Gender,
294+
&user.TwoFAEnabled, &user.TwoFAImage, &user.TwoFASecret,
198295
)
199296
if err != nil {
200297
log.Println("Error scanning row:", err)

internal/handlers/user/user_handlers.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,68 @@ func (h *Handlers) UploadProfilePicture(c *gin.Context) {
355355
"data": user.ProfilePicture,
356356
})
357357
}
358+
359+
func (h *Handlers) EnableTwoFA(c *gin.Context) {
360+
userID, err := (&auth.Handlers{}).ExtractUserIDAndCheckPermission(c, "users:2fa")
361+
if err != nil {
362+
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": []string{err.Error()}})
363+
return
364+
}
365+
366+
qr, setupKey, err := h.UserService.EnableTwoFA(userID)
367+
if err != nil {
368+
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": []string{err.Error()}})
369+
return
370+
}
371+
372+
c.JSON(http.StatusOK, gin.H{
373+
"success": true,
374+
"message": "Two Factor Authentication Enabled Successfully",
375+
"data": gin.H{
376+
"qr_image": qr,
377+
"setup_key": setupKey,
378+
},
379+
})
380+
}
381+
382+
func (h *Handlers) VerifyTwoFA(c *gin.Context) {
383+
userID, err := (&auth.Handlers{}).ExtractUserIDAndCheckPermission(c, "users:2fa")
384+
if err != nil {
385+
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": []string{err.Error()}})
386+
return
387+
}
388+
389+
var request struct {
390+
Code string `json:"code"`
391+
}
392+
if err := c.BindJSON(&request); err != nil {
393+
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
394+
return
395+
}
396+
valid, err := h.UserService.VerifyTwoFA(userID, request.Code)
397+
if err != nil {
398+
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
399+
return
400+
}
401+
if !valid {
402+
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "Invalid TOTP Code"})
403+
return
404+
}
405+
c.JSON(http.StatusOK, gin.H{"success": true, "message": "TOTP Verified Successfully"})
406+
}
407+
408+
func (h *Handlers) DisableTwoFA(c *gin.Context) {
409+
userID, err := (&auth.Handlers{}).ExtractUserIDAndCheckPermission(c, "users:2fa")
410+
if err != nil {
411+
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": []string{err.Error()}})
412+
return
413+
}
414+
415+
err = h.UserService.DisableTwoFA(userID)
416+
if err != nil {
417+
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
418+
return
419+
}
420+
421+
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Two Factor Authentication Disabled Successfully"})
422+
}

internal/models/user.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,7 @@ type User struct {
3030
InstitutionName *string `json:"institution_name"`
3131
Gender string `json:"gender"`
3232
AdditionalNotes *string `json:"additional_notes"`
33+
TwoFAEnabled bool `json:"2fa_enabled"`
34+
TwoFAImage *string `json:"2fa_image"`
35+
TwoFASecret *string `json:"2fa_secret"`
3336
}

internal/services/user_services.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ package services
33
import (
44
"Backend/internal/database/app"
55
"Backend/internal/models"
6+
"Backend/pkg/utils"
67
"errors"
8+
"fmt"
79
"github.com/google/uuid"
10+
"github.com/pquerna/otp"
11+
"github.com/pquerna/otp/totp"
812
"log"
13+
"time"
914
)
1015

1116
type UserService struct{}
@@ -106,3 +111,91 @@ func (us *UserService) AdminUpdateRoleAndStudentIDVerified(userID uuid.UUID, rol
106111
func (us *UserService) UploadProfilePicture(userID uuid.UUID, profilePicture string) error {
107112
return app.UploadProfilePicture(userID, profilePicture)
108113
}
114+
115+
func (us *UserService) EnableTwoFA(userID uuid.UUID) (string, string, error) {
116+
user, err := app.GetUserByID(userID)
117+
if err != nil {
118+
return "", "", err
119+
}
120+
121+
log.Println("Generating TOTP key")
122+
123+
secret, err := utils.GenerateTOTPKey(user.Email)
124+
if err != nil {
125+
return "", "", err
126+
}
127+
128+
log.Println("Generated TOTP secret:", secret.Secret())
129+
130+
log.Println("Generating QR code")
131+
132+
qr, err := utils.GenerateQRCodeBase64(secret)
133+
if err != nil {
134+
return "", "", err
135+
}
136+
137+
log.Println("Updating user")
138+
139+
secretStr := secret.Secret()
140+
141+
user.TwoFASecret = &secretStr
142+
user.TwoFAEnabled = true
143+
user.TwoFAImage = &qr
144+
145+
log.Println("User updated with TOTP secret:", secretStr)
146+
147+
err = app.UpdateUser(userID, user)
148+
if err != nil {
149+
return "", "", err
150+
}
151+
152+
return qr, secretStr, nil
153+
}
154+
155+
func (us *UserService) VerifyTwoFA(userID uuid.UUID, code string) (bool, error) {
156+
user, err := app.GetUserByID(userID)
157+
if err != nil {
158+
return false, err
159+
}
160+
161+
if user.TwoFASecret == nil {
162+
log.Println("No TOTP secret found for user")
163+
return false, fmt.Errorf("no TOTP secret found for user")
164+
}
165+
166+
log.Println("Verifying TOTP code with secret:", *user.TwoFASecret)
167+
log.Println("TOTP code to verify:", code)
168+
169+
// Ensure the correct settings for TOTP validation
170+
valid, err := totp.ValidateCustom(code, *user.TwoFASecret, time.Now(), totp.ValidateOpts{
171+
Period: 30,
172+
Skew: 1,
173+
Digits: otp.DigitsEight,
174+
Algorithm: otp.AlgorithmSHA256,
175+
})
176+
177+
if err != nil {
178+
return false, err
179+
}
180+
181+
log.Println("Is TOTP code valid?", valid)
182+
return valid, nil
183+
}
184+
185+
func (us *UserService) DisableTwoFA(userID uuid.UUID) error {
186+
user, err := app.GetUserByID(userID)
187+
if err != nil {
188+
return err
189+
}
190+
191+
user.TwoFASecret = nil
192+
user.TwoFAEnabled = false
193+
user.TwoFAImage = nil
194+
195+
err = app.UpdateUser(userID, user)
196+
if err != nil {
197+
return err
198+
}
199+
200+
return nil
201+
}

migrations/000018_2fa.down.sql

Whitespace-only changes.

0 commit comments

Comments
 (0)