From 2bf434f00e0313a2004c975a8141d74943ca9fe4 Mon Sep 17 00:00:00 2001
From: Thomas Miceli <27960254+thomiceli@users.noreply.github.com>
Date: Thu, 24 Oct 2024 23:23:00 +0200
Subject: [PATCH] Add TOTP MFA (#342)
---
go.mod | 2 +
go.sum | 5 +
internal/auth/totp/totp.go | 61 +++++++++++
internal/config/config.go | 4 +
internal/db/db.go | 4 +-
internal/db/totp.go | 121 ++++++++++++++++++++
internal/db/types.go | 37 +++++++
internal/db/user.go | 14 ++-
internal/i18n/locales/en-US.yml | 17 +++
internal/utils/aes.go | 46 ++++++++
internal/utils/session.go | 2 +-
internal/web/auth.go | 189 +++++++++++++++++++++++++++++++-
internal/web/server.go | 10 +-
internal/web/settings.go | 6 +
internal/web/util.go | 1 +
public/tailwind.config.js | 1 +
templates/base/base_header.html | 14 +++
templates/pages/mfa.html | 25 ++++-
templates/pages/settings.html | 32 +++++-
templates/pages/totp.html | 54 +++++++++
20 files changed, 629 insertions(+), 16 deletions(-)
create mode 100644 internal/auth/totp/totp.go
create mode 100644 internal/db/totp.go
create mode 100644 internal/utils/aes.go
create mode 100644 templates/pages/totp.html
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 @@
{{ .locale.Tr "auth.mfa.waiting-for-passkey-input" }}
+ {{ end }}
+
+ {{ if .hasTotp }}
+
+
+
{{ .locale.Tr "auth.totp.enter-code" }}
+
+
+
{{ .locale.Tr "auth.totp.enter-recovery-key" }}
+
+
+
+ {{ 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 }}
+
+
+
+
+ {{ else }}
+
{{ .locale.Tr "auth.totp.use" }}
+ {{ end }}
+
+
+
@@ -162,7 +188,7 @@
{{ .csrfHtml }}
@@ -273,9 +299,9 @@
{{ .locale.Tr "settings.delete-account" }}
-
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" .}}
+
+
+
+
+ {{ if .recoveryCodes }}
+ {{ .locale.Tr "auth.totp.save-recovery-codes" }}
+
+
+
+
+
+ {{ range .recoveryCodes }}
+ - {{ . }}
+ {{ end }}
+
+
+
+
+
+
+
+ {{ else }}
+ {{ .locale.Tr "auth.totp.scan-qr-code" }}
+
+
+
+
{{.totpSecret}}
+
+
+
+
+ {{ end }}
+
+
+
+
+
+{{ template "footer" .}}