diff --git a/go.mod b/go.mod index e4505ab..fccbe31 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/hashicorp/go-memdb v1.3.4 github.com/labstack/echo/v4 v4.12.0 github.com/markbates/goth v1.80.0 + github.com/pquerna/otp v1.4.0 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.9.0 github.com/urfave/cli/v2 v2.27.2 @@ -51,6 +52,7 @@ require ( github.com/blevesearch/zapx/v14 v14.3.10 // indirect github.com/blevesearch/zapx/v15 v15.3.13 // indirect github.com/blevesearch/zapx/v16 v16.1.0 // indirect + github.com/boombuler/barcode v1.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect diff --git a/go.sum b/go.sum index 32236c8..ef959e3 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,9 @@ github.com/blevesearch/zapx/v15 v15.3.13 h1:6EkfaZiPlAxqXz0neniq35my6S48QI94W/wy github.com/blevesearch/zapx/v15 v15.3.13/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg= github.com/blevesearch/zapx/v16 v16.1.0 h1:bHsyowFqU0QA+uVDJCjifv9OvPGb8htkV52Yc/wT6xs= github.com/blevesearch/zapx/v16 v16.1.0/go.mod h1:P0h9lKRyl4EKksAWfxwCQ5I5pLB9jH2XD8bhYHuIYuc= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= +github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 h1:wMSvdj3BswqfQOXp2R1bJOAE7xIQLt2dlMQDMf836VY= github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA= @@ -189,6 +192,8 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= diff --git a/internal/auth/totp/totp.go b/internal/auth/totp/totp.go new file mode 100644 index 0000000..2281f87 --- /dev/null +++ b/internal/auth/totp/totp.go @@ -0,0 +1,61 @@ +package totp + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "github.com/pquerna/otp/totp" + "html/template" + "image/png" + "strings" +) + +const secretSize = 16 + +func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.URL, error, []byte) { + var err error + if secret == nil { + secret, err = generateSecret() + if err != nil { + return "", "", err, nil + } + } + + otpKey, err := totp.Generate(totp.GenerateOpts{ + SecretSize: secretSize, + Issuer: "Opengist (" + strings.ReplaceAll(siteUrl, ":", "") + ")", + AccountName: username, + Secret: secret, + }) + if err != nil { + return "", "", err, nil + } + + qrcode, err := otpKey.Image(320, 240) + if err != nil { + return "", "", err, nil + } + + var imgBytes bytes.Buffer + if err = png.Encode(&imgBytes, qrcode); err != nil { + return "", "", err, nil + } + + qrcodeImage := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes())) + + return otpKey.Secret(), qrcodeImage, nil, secret +} + +func Validate(passcode, secret string) bool { + return totp.Validate(passcode, secret) +} + +func generateSecret() ([]byte, error) { + secret := make([]byte, secretSize) + _, err := rand.Reader.Read(secret) + if err != nil { + return nil, err + } + + return secret, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 1d65396..1d4e1f4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,8 @@ var OpengistVersion = "" var C *config +var SecretKey []byte + // Not using nested structs because the library // doesn't support dot notation in this case sadly type config struct { @@ -136,6 +138,8 @@ func InitConfig(configPath string, out io.Writer) error { C = c + // SecretKey = utils.GenerateSecretKey(filepath.Join(GetHomeDir(), "opengist-secret.key")) + if err = os.Setenv("OG_OPENGIST_HOME_INTERNAL", GetHomeDir()); err != nil { return err } diff --git a/internal/db/db.go b/internal/db/db.go index f8b4b30..6c7f85c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -137,7 +137,7 @@ func Setup(dbUri string, sharedCache bool) error { return err } - if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}); err != nil { + if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}); err != nil { return err } @@ -241,5 +241,5 @@ func DeprecationDBFilename() { } func TruncateDatabase() error { - return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}) + return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}) } diff --git a/internal/db/totp.go b/internal/db/totp.go new file mode 100644 index 0000000..60e30f4 --- /dev/null +++ b/internal/db/totp.go @@ -0,0 +1,121 @@ +package db + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + ogtotp "github.com/thomiceli/opengist/internal/auth/totp" + "github.com/thomiceli/opengist/internal/utils" + "slices" +) + +type TOTP struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"uniqueIndex"` + User User + Secret string + RecoveryCodes jsonData `gorm:"type:json"` + CreatedAt int64 + LastUsedAt int64 +} + +func GetTOTPByUserID(userID uint) (*TOTP, error) { + var totp TOTP + err := db.Where("user_id = ?", userID).First(&totp).Error + return &totp, err +} + +func (totp *TOTP) StoreSecret(secret string) error { + secretBytes := []byte(secret) + encrypted, err := utils.AESEncrypt([]byte("tmp"), secretBytes) + if err != nil { + return err + } + + totp.Secret = base64.URLEncoding.EncodeToString(encrypted) + return nil +} + +func (totp *TOTP) ValidateCode(code string) (bool, error) { + ciphertext, err := base64.URLEncoding.DecodeString(totp.Secret) + if err != nil { + return false, err + } + + secretBytes, err := utils.AESDecrypt([]byte("tmp"), ciphertext) + if err != nil { + return false, err + } + + return ogtotp.Validate(code, string(secretBytes)), nil +} + +func (totp *TOTP) ValidateRecoveryCode(code string) (bool, error) { + var hashedCodes []string + if err := json.Unmarshal(totp.RecoveryCodes, &hashedCodes); err != nil { + return false, err + } + + for i, hashedCode := range hashedCodes { + ok, err := utils.Argon2id.Verify(code, hashedCode) + if err != nil { + return false, err + } + if ok { + codesJson, _ := json.Marshal(slices.Delete(hashedCodes, i, i+1)) + totp.RecoveryCodes = codesJson + return true, db.Model(&totp).Updates(TOTP{RecoveryCodes: codesJson}).Error + } + } + return false, nil +} + +func (totp *TOTP) GenerateRecoveryCodes() ([]string, error) { + codes, plainCodes, err := generateRandomCodes() + if err != nil { + return nil, err + } + + codesJson, _ := json.Marshal(codes) + totp.RecoveryCodes = codesJson + + return plainCodes, db.Model(&totp).Updates(TOTP{RecoveryCodes: codesJson}).Error +} + +func (totp *TOTP) Create() error { + return db.Create(&totp).Error +} + +func (totp *TOTP) Delete() error { + return db.Delete(&totp).Error +} + +func generateRandomCodes() ([]string, []string, error) { + const count = 5 + const length = 10 + codes := make([]string, count) + plainCodes := make([]string, count) + for i := 0; i < count; i++ { + bytes := make([]byte, (length+1)/2) + if _, err := rand.Read(bytes); err != nil { + return nil, nil, err + } + hexCode := hex.EncodeToString(bytes) + code := fmt.Sprintf("%s-%s", hexCode[:length/2], hexCode[length/2:]) + plainCodes[i] = code + hashed, err := utils.Argon2id.Hash(code) + if err != nil { + return nil, nil, err + } + codes[i] = hashed + } + return codes, plainCodes, nil +} + +// -- DTO -- // + +type TOTPDTO struct { + Code string `form:"code" validate:"max=50"` +} diff --git a/internal/db/types.go b/internal/db/types.go index f3e8d2d..eb37f60 100644 --- a/internal/db/types.go +++ b/internal/db/types.go @@ -2,6 +2,8 @@ package db import ( "database/sql/driver" + "encoding/json" + "errors" "fmt" "gorm.io/gorm" "gorm.io/gorm/schema" @@ -38,3 +40,38 @@ func (*binaryData) GormDBDataType(db *gorm.DB, _ *schema.Field) string { return "BLOB" } } + +type jsonData json.RawMessage + +func (j *jsonData) Scan(value interface{}) error { + bytes, ok := value.([]byte) + if !ok { + return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value)) + } + + result := json.RawMessage{} + err := json.Unmarshal(bytes, &result) + *j = jsonData(result) + return err +} + +func (j *jsonData) Value() (driver.Value, error) { + if len(*j) == 0 { + return nil, nil + } + return json.RawMessage(*j).MarshalJSON() +} + +func (*jsonData) GormDataType() string { + return "json" +} + +func (*jsonData) GormDBDataType(db *gorm.DB, _ *schema.Field) string { + switch db.Dialector.Name() { + case "mysql", "sqlite": + return "JSON" + case "postgres": + return "JSONB" + } + return "" +} diff --git a/internal/db/user.go b/internal/db/user.go index d3e2045..d7ad00e 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -206,11 +206,17 @@ func (user *User) DeleteProviderID(provider string) error { return nil } -func (user *User) HasMFA() (bool, error) { - var exists bool - err := db.Model(&WebAuthnCredential{}).Select("count(*) > 0").Where("user_id = ?", user.ID).Find(&exists).Error +func (user *User) HasMFA() (bool, bool, error) { + var webauthn bool + var totp bool + err := db.Model(&WebAuthnCredential{}).Select("count(*) > 0").Where("user_id = ?", user.ID).Find(&webauthn).Error + if err != nil { + return false, false, err + } - return exists, err + err = db.Model(&TOTP{}).Select("count(*) > 0").Where("user_id = ?", user.ID).Find(&totp).Error + + return webauthn, totp, err } // -- DTO -- // diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index 75a2312..1dcfcb3 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -158,6 +158,23 @@ auth.mfa.passkey-added-at: Added auth.mfa.passkey-never-used: Never used auth.mfa.passkey-last-used: Last used auth.mfa.delete-passkey-confirm: Confirm deletion of passkey +auth.totp: Time based one-time password (TOTP) +auth.totp.help: TOTP is a two-factor authentication method that uses a shared secret to generate a one-time password. +auth.totp.use: Use TOTP +auth.totp.regenerate-recovery-codes: Regenerate recovery codes +auth.totp.already-enabled: TOTP is already enabled +auth.totp.invalid-secret: Invalid TOTP secret +auth.totp.invalid-code: Invalid TOTP code +auth.totp.code-used: The recovery code %s was used, it is now invalid. You may want to disable MFA for now or regenerate your codes. +auth.totp.disabled: TOTP successfully disabled +auth.totp.disable: Disable TOTP +auth.totp.enter-code: Enter the code from the Authenticator app +auth.totp.enter-recovery-key: or a recovery key if you lost your device +auth.totp.code: Code +auth.totp.submit: Submit +auth.totp.proceed: Proceed +auth.totp.save-recovery-codes: Save your recovery codes in a safe place. You can use these codes to recover access to your account if you lose access to your authenticator app. +auth.totp.scan-qr-code: Scan the QR code below with your authenticator app to enable two-factor authentication or enter the following string, then confirm with the generated code. error: Error diff --git a/internal/utils/aes.go b/internal/utils/aes.go new file mode 100644 index 0000000..c401d35 --- /dev/null +++ b/internal/utils/aes.go @@ -0,0 +1,46 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" + "io" +) + +func AESEncrypt(key, text []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + ciphertext := make([]byte, aes.BlockSize+len(text)) + iv := ciphertext[:aes.BlockSize] + if _, err = io.ReadFull(rand.Reader, iv); err != nil { + return nil, err + } + + stream := cipher.NewCFBEncrypter(block, iv) + stream.XORKeyStream(ciphertext[aes.BlockSize:], text) + + return ciphertext, nil +} + +func AESDecrypt(key, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + if len(ciphertext) < aes.BlockSize { + return nil, fmt.Errorf("ciphertext too short") + } + + iv := ciphertext[:aes.BlockSize] + ciphertext = ciphertext[aes.BlockSize:] + + stream := cipher.NewCFBDecrypter(block, iv) + stream.XORKeyStream(ciphertext, ciphertext) + + return ciphertext, nil +} diff --git a/internal/utils/session.go b/internal/utils/session.go index 74eadc0..74e0892 100644 --- a/internal/utils/session.go +++ b/internal/utils/session.go @@ -6,7 +6,7 @@ import ( "os" ) -func ReadKey(filePath string) []byte { +func GenerateSecretKey(filePath string) []byte { key, err := os.ReadFile(filePath) if err == nil { return key diff --git a/internal/web/auth.go b/internal/web/auth.go index 6db4c6f..f7d1b15 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -15,6 +15,7 @@ import ( "github.com/markbates/goth/providers/gitlab" "github.com/markbates/goth/providers/openidConnect" "github.com/rs/zerolog/log" + "github.com/thomiceli/opengist/internal/auth/totp" "github.com/thomiceli/opengist/internal/auth/webauthn" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" @@ -169,12 +170,13 @@ func processLogin(ctx echo.Context) error { } // handle MFA - var hasMFA bool - if hasMFA, err = user.HasMFA(); err != nil { + var hasWebauthn, hasTotp bool + if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil { return errorRes(500, "Cannot check for user MFA", err) } - if hasMFA { + if hasWebauthn || hasTotp { sess.Values["mfaID"] = user.ID + sess.Options.MaxAge = 5 * 60 // 5 minutes saveSession(sess, ctx) return redirect(ctx, "/mfa") } @@ -188,6 +190,18 @@ func processLogin(ctx echo.Context) error { } func mfa(ctx echo.Context) error { + var err error + + user := db.User{ID: getSession(ctx).Values["mfaID"].(uint)} + + var hasWebauthn, hasTotp bool + if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil { + return errorRes(500, "Cannot check for user MFA", err) + } + + setData(ctx, "hasWebauthn", hasWebauthn) + setData(ctx, "hasTotp", hasTotp) + return html(ctx, "mfa.html") } @@ -534,6 +548,175 @@ func finishWebAuthnAssertion(ctx echo.Context) error { return json(ctx, 200, []string{"OK"}) } +func beginTotp(ctx echo.Context) error { + user := getUserLogged(ctx) + + if _, hasTotp, err := user.HasMFA(); err != nil { + return errorRes(500, "Cannot check for user MFA", err) + } else if hasTotp { + addFlash(ctx, tr(ctx, "auth.totp.already-enabled"), "error") + return redirect(ctx, "/settings") + } + + ogUrl, err := url.Parse(getData(ctx, "baseHttpUrl").(string)) + if err != nil { + return errorRes(500, "Cannot parse base URL", err) + } + + sess := getSession(ctx) + generatedSecret, _ := sess.Values["generatedSecret"].([]byte) + + totpSecret, qrcode, err, generatedSecret := totp.GenerateQRCode(getUserLogged(ctx).Username, ogUrl.Hostname(), generatedSecret) + if err != nil { + return errorRes(500, "Cannot generate TOTP QR code", err) + } + sess.Values["totpSecret"] = totpSecret + sess.Values["generatedSecret"] = generatedSecret + saveSession(sess, ctx) + + setData(ctx, "totpSecret", totpSecret) + setData(ctx, "totpQrcode", qrcode) + + return html(ctx, "totp.html") + +} + +func finishTotp(ctx echo.Context) error { + user := getUserLogged(ctx) + + if _, hasTotp, err := user.HasMFA(); err != nil { + return errorRes(500, "Cannot check for user MFA", err) + } else if hasTotp { + addFlash(ctx, tr(ctx, "auth.totp.already-enabled"), "error") + return redirect(ctx, "/settings") + } + + dto := &db.TOTPDTO{} + if err := ctx.Bind(dto); err != nil { + return errorRes(400, tr(ctx, "error.cannot-bind-data"), err) + } + + if err := ctx.Validate(dto); err != nil { + addFlash(ctx, "Invalid secret", "error") + return redirect(ctx, "/settings/totp/generate") + } + + sess := getSession(ctx) + secret, ok := sess.Values["totpSecret"].(string) + if !ok { + return errorRes(500, "Cannot get TOTP secret from session", nil) + } + + if !totp.Validate(dto.Code, secret) { + addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error") + + return redirect(ctx, "/settings/totp/generate") + } + + userTotp := &db.TOTP{ + UserID: getUserLogged(ctx).ID, + } + if err := userTotp.StoreSecret(secret); err != nil { + return errorRes(500, "Cannot store TOTP secret", err) + } + + if err := userTotp.Create(); err != nil { + return errorRes(500, "Cannot create TOTP", err) + } + + addFlash(ctx, "TOTP successfully enabled", "success") + codes, err := userTotp.GenerateRecoveryCodes() + if err != nil { + return errorRes(500, "Cannot generate recovery codes", err) + } + + delete(sess.Values, "totpSecret") + delete(sess.Values, "generatedSecret") + saveSession(sess, ctx) + + setData(ctx, "recoveryCodes", codes) + return html(ctx, "totp.html") +} + +func assertTotp(ctx echo.Context) error { + var err error + dto := &db.TOTPDTO{} + if err := ctx.Bind(dto); err != nil { + return errorRes(400, tr(ctx, "error.cannot-bind-data"), err) + } + + if err := ctx.Validate(dto); err != nil { + addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error") + return redirect(ctx, "/mfa") + } + + sess := getSession(ctx) + userId := sess.Values["mfaID"].(uint) + var userTotp *db.TOTP + if userTotp, err = db.GetTOTPByUserID(userId); err != nil { + return errorRes(500, "Cannot get TOTP by UID", err) + } + + redirectUrl := "/" + + var validCode, validRecoveryCode bool + if validCode, err = userTotp.ValidateCode(dto.Code); err != nil { + return errorRes(500, "Cannot validate TOTP code", err) + } + if !validCode { + validRecoveryCode, err = userTotp.ValidateRecoveryCode(dto.Code) + if err != nil { + return errorRes(500, "Cannot validate TOTP code", err) + } + + if !validRecoveryCode { + addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error") + return redirect(ctx, "/mfa") + } + + addFlash(ctx, tr(ctx, "auth.totp.code-used", dto.Code), "warning") + redirectUrl = "/settings" + } + + sess.Values["user"] = userId + sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year + delete(sess.Values, "mfaID") + saveSession(sess, ctx) + + return redirect(ctx, redirectUrl) +} + +func disableTotp(ctx echo.Context) error { + user := getUserLogged(ctx) + userTotp, err := db.GetTOTPByUserID(user.ID) + if err != nil { + return errorRes(500, "Cannot get TOTP by UID", err) + } + + if err = userTotp.Delete(); err != nil { + return errorRes(500, "Cannot delete TOTP", err) + } + + addFlash(ctx, tr(ctx, "auth.totp.disabled"), "success") + return redirect(ctx, "/settings") +} + +func regenerateTotpRecoveryCodes(ctx echo.Context) error { + user := getUserLogged(ctx) + userTotp, err := db.GetTOTPByUserID(user.ID) + if err != nil { + return errorRes(500, "Cannot get TOTP by UID", err) + } + + codes, err := userTotp.GenerateRecoveryCodes() + if err != nil { + return errorRes(500, "Cannot generate recovery codes", err) + } + + setData(ctx, "recoveryCodes", codes) + return html(ctx, "totp.html") +} + func logout(ctx echo.Context) error { deleteSession(ctx) deleteCsrfCookie(ctx) diff --git a/internal/web/server.go b/internal/web/server.go index cbbc485..fbaeb12 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -168,8 +168,8 @@ func NewServer(isDev bool, sessionsPath string) *Server { dev = isDev flashStore = sessions.NewCookieStore([]byte("opengist")) userStore = sessions.NewFilesystemStore(sessionsPath, - utils.ReadKey(path.Join(sessionsPath, "session-auth.key")), - utils.ReadKey(path.Join(sessionsPath, "session-encrypt.key")), + utils.GenerateSecretKey(path.Join(sessionsPath, "session-auth.key")), + utils.GenerateSecretKey(path.Join(sessionsPath, "session-encrypt.key")), ) userStore.MaxLength(10 * 1024) gothic.Store = userStore @@ -274,6 +274,7 @@ func NewServer(isDev bool, sessionsPath string) *Server { g1.POST("/webauthn/assertion", beginWebAuthnAssertion, inMFASession) g1.POST("/webauthn/assertion/finish", finishWebAuthnAssertion, inMFASession) g1.GET("/mfa", mfa, inMFASession) + g1.POST("/mfa/totp/assertion", assertTotp, inMFASession) g1.GET("/settings", userSettings, logged) g1.POST("/settings/email", emailProcess, logged) @@ -283,6 +284,11 @@ func NewServer(isDev bool, sessionsPath string) *Server { g1.DELETE("/settings/passkeys/:id", passkeyDelete, logged) g1.PUT("/settings/password", passwordProcess, logged) g1.PUT("/settings/username", usernameProcess, logged) + g1.GET("/settings/totp/generate", beginTotp, logged) + g1.POST("/settings/totp/generate", finishTotp, logged) + g1.DELETE("/settings/totp", disableTotp, logged) + g1.POST("/settings/totp/regenerate", regenerateTotpRecoveryCodes, logged) + g2 := g1.Group("/admin-panel") { g2.Use(adminPermission) diff --git a/internal/web/settings.go b/internal/web/settings.go index 57cf407..f6b6324 100644 --- a/internal/web/settings.go +++ b/internal/web/settings.go @@ -31,9 +31,15 @@ func userSettings(ctx echo.Context) error { return errorRes(500, "Cannot get WebAuthn credentials", err) } + _, hasTotp, err := user.HasMFA() + if err != nil { + return errorRes(500, "Cannot get MFA status", err) + } + setData(ctx, "email", user.Email) setData(ctx, "sshKeys", keys) setData(ctx, "passkeys", passkeys) + setData(ctx, "hasTotp", hasTotp) setData(ctx, "hasPassword", user.Password != "") setData(ctx, "disableForm", getData(ctx, "DisableLoginForm")) setData(ctx, "htmlTitle", trH(ctx, "settings")) diff --git a/internal/web/util.go b/internal/web/util.go index cc165b8..11d75b5 100644 --- a/internal/web/util.go +++ b/internal/web/util.go @@ -101,6 +101,7 @@ func setErrorFlashes(ctx echo.Context) { setData(ctx, "flashErrors", sess.Flashes("error")) setData(ctx, "flashSuccess", sess.Flashes("success")) + setData(ctx, "flashWarnings", sess.Flashes("warning")) _ = sess.Save(ctx.Request(), ctx.Response()) } diff --git a/public/tailwind.config.js b/public/tailwind.config.js index e7de624..fc7a346 100644 --- a/public/tailwind.config.js +++ b/public/tailwind.config.js @@ -11,6 +11,7 @@ module.exports = { current: 'currentColor', white: colors.white, black: colors.black, + yellow: colors.yellow, gray: { 50: "#EEEFF1", 100: "#DEDFE3", diff --git a/templates/base/base_header.html b/templates/base/base_header.html index 232014f..33826c1 100644 --- a/templates/base/base_header.html +++ b/templates/base/base_header.html @@ -302,6 +302,20 @@ {{end}} + {{range .flashWarnings}} +
+
+
+ + + +
+
+

{{.}}

+
+
+
+ {{end}} {{ end }} diff --git a/templates/pages/mfa.html b/templates/pages/mfa.html index 27c7e5e..943b41c 100644 --- a/templates/pages/mfa.html +++ b/templates/pages/mfa.html @@ -7,7 +7,8 @@
-
+
+ {{ if .hasWebauthn }}

{{ .locale.Tr "auth.mfa.use-passkey-to-finish" }}

@@ -27,6 +28,28 @@
+ {{ end }} + + {{ if .hasTotp }} +
+
+

{{ .locale.Tr "auth.totp.enter-code" }}

+
+
+

{{ .locale.Tr "auth.totp.enter-recovery-key" }}

+
+
+
+ {{ .csrfHtml }} + +
+ +
+ +
+
+
+ {{ end }}
diff --git a/templates/pages/settings.html b/templates/pages/settings.html index 34b658e..8e43834 100644 --- a/templates/pages/settings.html +++ b/templates/pages/settings.html @@ -149,6 +149,32 @@ {{ end }} +
+
+

+ {{ .locale.Tr "auth.totp" }} +

+

+ {{ .locale.Tr "auth.totp.help" }} +

+ {{ if .hasTotp }} +
+
+ + {{ .csrfHtml }} + +
+
+ {{ .csrfHtml }} + +
+
+ {{ else }} + {{ .locale.Tr "auth.totp.use" }} + {{ end }} +
+
+
@@ -162,7 +188,7 @@
- +
{{ .csrfHtml }} @@ -273,9 +299,9 @@

{{ .locale.Tr "settings.delete-account" }}

-
+ - + {{ .csrfHtml }}
diff --git a/templates/pages/totp.html b/templates/pages/totp.html new file mode 100644 index 0000000..738393f --- /dev/null +++ b/templates/pages/totp.html @@ -0,0 +1,54 @@ +{{ template "header" .}} + +
+
+
+

{{ .locale.Tr "auth.totp" }}

+
+
+
+ {{ if .recoveryCodes }} +

{{ .locale.Tr "auth.totp.save-recovery-codes" }}

+ +
+
+
    + + {{ range .recoveryCodes }} +
  • {{ . }}
  • + {{ end }} +
    +
+
+ +
+ + + {{ else }} +

{{ .locale.Tr "auth.totp.scan-qr-code" }}

+ +
+
+

{{.totpSecret}}

+ {{.totpSecret}} +
+
+
+ {{ .csrfHtml }} + +
+ +
+ +
+
+
+ {{ end }} +
+
+ + + +{{ template "footer" .}}