WIP: Add Two Factor Authentication #7

Draft
adoralaura wants to merge 11 commits from feature-2fa into main
7 changed files with 104 additions and 26 deletions
Showing only changes of commit cfccdc7caf - Show all commits

View file

@ -46,6 +46,11 @@ func InitializeDB() error {
return fmt.Errorf("[DB] couldn't create database: [%w]", err) return fmt.Errorf("[DB] couldn't create database: [%w]", err)
} }
_, err = models.DB.NewCreateTable().IfNotExists().Model((*models.MFAScratchCode)(nil)).Exec(context.Background())
if err != nil {
return fmt.Errorf("[DB] couldn't create database: [%w]", err)
}
err = doMigrations() err = doMigrations()
if err != nil { if err != nil {
return fmt.Errorf("[DB] Error during Migrations: [%w]", err) return fmt.Errorf("[DB] Error during Migrations: [%w]", err)

View file

@ -18,3 +18,18 @@ func UserHasMFA(user models.User) (bool, error) {
} }
return false, nil return false, nil
} }
// ScratchCodeUnique checks the database if the generated scratch code
// is unique (not in the database yet)
func ScratchCodeIsUnique(scratchcode string) bool {
var dbitem models.MFAScratchCode
numrows, err := models.DB.NewSelect().Model(&dbitem).Where("code = ?", scratchcode).Count(context.Background())
if err != nil {
return false
}
if numrows != 0 {
return false
}
return true
}

View file

@ -2,6 +2,7 @@ package web
import ( import (
"context" "context"
"fmt"
"log" "log"
"slices" "slices"
@ -45,20 +46,28 @@ func HandleAdminLoginMFAPost(c *fiber.Ctx) error {
} }
// check token/scratch validity // check token/scratch validity
var passcodeIsValid bool var scratchcodeIsValid bool
if istotp { if istotp {
passcodeIsValid = checkTotpIsValid(token.Token, user) scratchcodeIsValid = checkTotpIsValid(token.Token, user)
} else { } else {
passcodeIsValid = checkScratchIsValid(token.Token, user) scratchcodeIsValid = checkScratchIsValid(token.Token, user)
} }
if passcodeIsValid { if scratchcodeIsValid {
err = misc.SetLoginCookie(c, user, constants.LoginCookieExpiryDuration) err = misc.SetLoginCookie(c, user, constants.LoginCookieExpiryDuration)
if err != nil { if err != nil {
log.Printf("[HandleAdminLoginPost] Error setting cookie: %q\n", err) log.Printf("[HandleAdminLoginPost] Error setting cookie: %q\n", err)
return misc.New500Error() return misc.New500Error()
} }
_, err = models.DB.NewUpdate().
Model(&models.MFAScratchCode{Code: token.Token, IsUsed: true}).
OmitZero().WherePK().Exec(context.Background())
if err != nil {
fmt.Printf("[HandleAdminLoginMFAPost] Error marking scratch code as used: %v\n", err.Error())
return fiber.NewError(fiber.StatusInternalServerError, "500 Internal Server Error")
}
c.Status(fiber.StatusOK) c.Status(fiber.StatusOK)
return nil return nil
} }
@ -81,16 +90,21 @@ func checkTotpIsValid(passcode string, user models.User) bool {
} }
func checkScratchIsValid(scratch string, user models.User) bool { func checkScratchIsValid(scratch string, user models.User) bool {
var mfaconfig models.MFAConfig var scratchcodes []models.MFAScratchCode
err := models.DB.NewSelect().Model(&mfaconfig).Where("username = ?", user.UserName).Scan(context.Background()) err := models.DB.NewSelect().Model(&scratchcodes).Where("username = ?", user.UserName).Where("is_used = ?", false).Scan(context.Background())
if err != nil { if err != nil {
log.Printf("Error getting MFA config for %v from DB: %v\n", user.UserName, err.Error()) log.Printf("Error getting MFA config for %v from DB: %v\n", user.UserName, err.Error())
// TODO: Debug logging // TODO: Debug logging
return false return false
} }
return slices.Contains(mfaconfig.RecoveryCodes, scratch) var scratchcodeSlice []string
for _, code := range scratchcodes {
scratchcodeSlice = append(scratchcodeSlice, code.Code)
}
return slices.Contains(scratchcodeSlice, scratch)
} }
func HandleAdminLoginMFAGet(c *fiber.Ctx) error { func HandleAdminLoginMFAGet(c *fiber.Ctx) error {

View file

@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"image/png" "image/png"
"log" "log"
"strconv" "strconv"
@ -18,12 +19,6 @@ import (
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
) )
type mfaSetupResponse struct {
Error bool `json:"error"`
Message string `json:"message,omitempty"`
RecoveryTokens []string `json:"recoverytokens,omitempty"`
}
func HandleAdminAccountMFASetupGet(c *fiber.Ctx) error { func HandleAdminAccountMFASetupGet(c *fiber.Ctx) error {
if !db.IsCookieValid(c.Cookies(constants.LoginCookieName, "")) { if !db.IsCookieValid(c.Cookies(constants.LoginCookieName, "")) {
c.Location("/admin/") c.Location("/admin/")
@ -36,15 +31,33 @@ func HandleAdminAccountMFASetupGet(c *fiber.Ctx) error {
mfaconfig.Active = false mfaconfig.Active = false
mfaconfig.ExpiresAt = time.Now().Add(15 * time.Minute) mfaconfig.ExpiresAt = time.Now().Add(15 * time.Minute)
for i := 0; i < 6; i++ {
mfaconfig.RecoveryCodes = append(mfaconfig.RecoveryCodes, misc.RandomString(8))
}
user, err := db.GetUserFromCookie(c.Cookies(constants.LoginCookieName)) user, err := db.GetUserFromCookie(c.Cookies(constants.LoginCookieName))
if err != nil { if err != nil {
log.Println(err) log.Println(err)
fiber.NewError(fiber.StatusInternalServerError, "500 Internal Server Error") fiber.NewError(fiber.StatusInternalServerError, "500 Internal Server Error")
} }
scratchcodes := []models.MFAScratchCode{}
scratchcodeFailed := 0
// generate four unique(!) scratch codes for the user
for len(scratchcodes) != 4 {
if scratchcodeFailed > 15 {
//TODO: structurized error logging
fmt.Println("[HandleAdminAccountMFASetupPost] Failed to generate unique scratch code 15 times! Aborting")
return misc.New500Error()
}
code := misc.RandomString(8)
if db.ScratchCodeIsUnique(code) {
scratchcodes = append(scratchcodes, models.MFAScratchCode{
IsUsed: false, Code: code, UserName: user.UserName})
} else {
scratchcodeFailed++
}
}
mfaconfig.UserName = user.UserName mfaconfig.UserName = user.UserName
key, err := totp.Generate(totp.GenerateOpts{ key, err := totp.Generate(totp.GenerateOpts{
@ -71,6 +84,12 @@ func HandleAdminAccountMFASetupGet(c *fiber.Ctx) error {
mfaobject.Image = base64img mfaobject.Image = base64img
_, err = models.DB.NewInsert().Model(&scratchcodes).Exec(context.Background())
if err != nil {
log.Printf("[HandleAdminAccountMFASetupGet] Error inserting scratch codes to DB: %q\n", err)
fiber.NewError(fiber.StatusInternalServerError, "500 Internal Server Error")
}
_, err = models.DB.NewInsert().Model(&mfaconfig).Exec(context.Background()) _, err = models.DB.NewInsert().Model(&mfaconfig).Exec(context.Background())
if err != nil { if err != nil {
log.Printf("[HandleAdminAccountMFASetupGet] Error inserting mfaconfig to DB: %q\n", err) log.Printf("[HandleAdminAccountMFASetupGet] Error inserting mfaconfig to DB: %q\n", err)
@ -88,11 +107,12 @@ func HandleAdminAccountMFASetupPost(c *fiber.Ctx) error {
return nil return nil
} }
var response mfaSetupResponse var response models.MFASetupResponse
response.Error = true response.Error = true
var token models.TokenRequest var token models.TokenRequest
var config models.MFAConfig var config models.MFAConfig
var scratchcodes []models.MFAScratchCode
//var user models.User //var user models.User
err := json.Unmarshal(c.Body(), &token) err := json.Unmarshal(c.Body(), &token)
@ -115,7 +135,7 @@ func HandleAdminAccountMFASetupPost(c *fiber.Ctx) error {
err = models.DB.NewSelect().Model(&config).Where("id = ?", setupcookie).Scan(context.Background()) err = models.DB.NewSelect().Model(&config).Where("id = ?", setupcookie).Scan(context.Background())
if err != nil { if err != nil {
log.Printf("[HandleAdminAccountMFASetupGet] Error getting MFAConfig from DB: %q\n", err) log.Printf("[HandleAdminAccountMFASetupPost] Error getting MFAConfig from DB: %q\n", err)
return fiber.NewError(fiber.StatusInternalServerError, "500 Internal Server Error") return fiber.NewError(fiber.StatusInternalServerError, "500 Internal Server Error")
} }
@ -123,7 +143,19 @@ func HandleAdminAccountMFASetupPost(c *fiber.Ctx) error {
if totpvalid { if totpvalid {
response.Error = false response.Error = false
response.Message = "Multifactor authentication was successfully set up!" response.Message = "Multifactor authentication was successfully set up!"
response.RecoveryTokens = config.RecoveryCodes
err = models.DB.NewSelect().Model(&scratchcodes).Where("username = ?", config.UserName).Scan(context.Background())
if err != nil {
log.Printf("[HandleAdminAccountMFASetupPost] Error getting MFA scratch codes from DB: %q\n", err)
return fiber.NewError(fiber.StatusInternalServerError, "500 Internal Server Error")
}
var scratchcodeSlice []string
for _, code := range scratchcodes {
scratchcodeSlice = append(scratchcodeSlice, code.Code)
}
response.RecoveryTokens = scratchcodeSlice
config.Active = true config.Active = true

View file

@ -26,12 +26,24 @@ type MFAConfig struct {
ID int64 `bun:"id,pk,autoincrement"` ID int64 `bun:"id,pk,autoincrement"`
UserName string `bun:"username,notnull"` UserName string `bun:"username,notnull"`
TOTPSecret string `bun:"totpurl,notnull"` TOTPSecret string `bun:"totpurl,notnull"`
RecoveryCodes []string `bun:"recoverycodes,notnull,array"`
ExpiresAt time.Time `bun:"expiresat,notnull"` ExpiresAt time.Time `bun:"expiresat,notnull"`
Active bool `bun:"active,notnull"` Active bool `bun:"active,notnull"`
} }
type MFAScratchCode struct {
bun.BaseModel `bun:"table:multifactor_scratchcodes"`
Code string `bun:"code,pk"`
UserName string `bun:"username,notnull"`
IsUsed bool `bun:"is_used,notnull"`
}
type MFATemplateObject struct { type MFATemplateObject struct {
Key string Key string
Image string Image string
} }
type MFASetupResponse struct {
Error bool `json:"error"`
Message string `json:"message,omitempty"`
RecoveryTokens []string `json:"recovery_tokens,omitempty"`
}

View file

@ -50,11 +50,11 @@
<p>Created at: {{ .Created }}</p> <p>Created at: {{ .Created }}</p>
<h2>Security</h2> <h2>Security</h2>
{{if not .HasMFA}} {{if .MFAEnabled}}
<a href="/admin/account/mfasetup" role="button">Setup 2fa</a>
{{else}}
<a role="button" class="secondary">2fa already enabled</a> <a role="button" class="secondary">2fa already enabled</a>
{{end}} {{else}}
<a href="/admin/account/mfasetup" role="button">Setup 2fa</a>
{{end}}
<dialog id="dialog-info"> <dialog id="dialog-info">
<h2 id="dialog-heading"></h2> <h2 id="dialog-heading"></h2>
<p id="dialog-text"></p> <p id="dialog-text"></p>

View file

@ -134,7 +134,7 @@ async function HandleMFASetupTokenSubmit() {
if (response.ok) { if (response.ok) {
let data = await response.json() let data = await response.json()
document.getElementById("dialog-tokens").textContent = data["recoverytokens"].join(" ") document.getElementById("dialog-tokens").textContent = data["recovery_tokens"].join(" ")
document.getElementById('dialog-success').showModal() document.getElementById('dialog-success').showModal()
} else { } else {
document.cookie = 'gourlsh_mfa_setup=; Max-Age=-1; path=/; domain=' + location.hostname; document.cookie = 'gourlsh_mfa_setup=; Max-Age=-1; path=/; domain=' + location.hostname;