Add TOTP MFA (#342)

This commit is contained in:
Thomas Miceli 2024-10-24 23:23:00 +02:00 committed by GitHub
parent df226cbd99
commit 2bf434f00e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 629 additions and 16 deletions

2
go.mod
View file

@ -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

5
go.sum
View file

@ -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=

View file

@ -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
}

View file

@ -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
}

View file

@ -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{})
}

121
internal/db/totp.go Normal file
View file

@ -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"`
}

View file

@ -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 ""
}

View file

@ -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 -- //

View file

@ -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

46
internal/utils/aes.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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"))

View file

@ -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())
}

View file

@ -11,6 +11,7 @@ module.exports = {
current: 'currentColor',
white: colors.white,
black: colors.black,
yellow: colors.yellow,
gray: {
50: "#EEEFF1",
100: "#DEDFE3",

View file

@ -302,6 +302,20 @@
</div>
</div>
{{end}}
{{range .flashWarnings}}
<div class="mt-4 rounded-md bg-gray-50 dark:bg-gray-800 border-l-4 border-yellow-500 dark:border-yellow-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-600 dark:text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-600 dark:text-yellow-400">{{.}}</p>
</div>
</div>
</div>
{{end}}
</div>
{{ end }}

View file

@ -7,7 +7,8 @@
</div>
</header>
<main>
<div class="mt-8 sm:w-full sm:max-w-md mx-auto">
<div class="mt-8 sm:w-full sm:max-w-md mx-auto grid gap-y-4">
{{ if .hasWebauthn }}
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10 ">
<div class="flex items-center justify-center">
<p class="block text-md font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.use-passkey-to-finish" }}</p>
@ -27,6 +28,28 @@
<p id="login-passkey-wait" class="hidden text-sm font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.waiting-for-passkey-input" }}</p>
</div>
</div>
{{ end }}
{{ if .hasTotp }}
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10 ">
<div class="flex items-center justify-center">
<p class="block text-md font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.totp.enter-code" }}</p>
</div>
<div class="flex items-center justify-center">
<p class="block text-sm font-medium items-center text-slate-600 dark:text-slate-400">{{ .locale.Tr "auth.totp.enter-recovery-key" }}</p>
</div>
<div class="flex items-center justify-center mt-4">
<form method="post" action="{{ $.c.ExternalUrl }}/mfa/totp/assertion">
{{ .csrfHtml }}
<label for="code" class="block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.totp.code" }}</label>
<div class="mt-1">
<input id="code" name="code" type="text" required autocomplete="off" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
</div>
<button type="submit" class="mt-2 inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.totp.submit" }}</button>
</form>
</div>
</div>
{{ end }}
</div>
</main>
</div>

View file

@ -149,6 +149,32 @@
</div>
{{ end }}
<div class="w-full">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
{{ .locale.Tr "auth.totp" }}
</h2>
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
{{ .locale.Tr "auth.totp.help" }}
</h3>
{{ if .hasTotp }}
<div class="flex">
<form method="post" action="{{ $.c.ExternalUrl }}/settings/totp" onconfirm="" class="mr-2">
<input type="hidden" name="_method" value="DELETE" />
{{ .csrfHtml }}
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">{{ .locale.Tr "auth.totp.disable" }}</button>
</form>
<form method="post" action="{{ $.c.ExternalUrl }}/settings/totp/regenerate" onconfirm="">
{{ .csrfHtml }}
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.totp.regenerate-recovery-codes" }}</button>
</form>
</div>
{{ else }}
<a href="{{ $.c.ExternalUrl }}/settings/totp/generate" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.totp.use" }}</a>
{{ end }}
</div>
</div>
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8">
<div class="w-full">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
@ -162,7 +188,7 @@
<div>
<label for="passkeyname" class="block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.passkey-name" }}</label>
<div class="mt-1">
<input id="passkeyname" name="passkeyname" type="text" required autocomplete="off" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
<input id="passkeyname" name="passkeyname" type="text" required autocomplete="off" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
</div>
</div>
{{ .csrfHtml }}
@ -273,9 +299,9 @@
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
{{ .locale.Tr "settings.delete-account" }}
</h2>
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/account" method="post" onsubmit="return confirm('{{ .locale.Tr "settings.delete-account-confirm" }}')">
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/account" method="post">
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500 mt-2">{{ .locale.Tr "settings.delete-account" }}</button>
<button type="submit" onclick="return confirm('{{ .locale.Tr "settings.delete-account-confirm" }}')" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500 mt-2">{{ .locale.Tr "settings.delete-account" }}</button>
{{ .csrfHtml }}
</form>
</div>

54
templates/pages/totp.html vendored Normal file
View file

@ -0,0 +1,54 @@
{{ template "header" .}}
<div class="py-10">
<header class="pb-4">
<div>
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "auth.totp" }}</h1>
</div>
</header>
<main>
{{ if .recoveryCodes }}
<p class="mb-4">{{ .locale.Tr "auth.totp.save-recovery-codes" }}</p>
<div class="mt-8 sm:w-full sm:max-w-md mx-auto flex">
<div class="mx-auto bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<ul class="list-none">
<code>
{{ range .recoveryCodes }}
<li>{{ . }}</li>
{{ end }}
</code>
</ul>
</div>
</div>
<div class="mt-8 sm:w-full sm:max-w-md mx-auto flex flex-col items-center">
<a href="{{ $.c.ExternalUrl }}/settings" class="px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.totp.proceed" }}</a>
</div>
{{ else }}
<p class="mb-2">{{ .locale.Tr "auth.totp.scan-qr-code" }}</p>
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8">
<div class="mt-4">
<p class="mb-4"><code>{{.totpSecret}}</code></p>
<img src="{{.totpQrcode}}" alt="{{.totpSecret}}">
</div>
<div>
<form method="post" action="{{ $.c.ExternalUrl }}/settings/totp/generate">
{{ .csrfHtml }}
<label for="code" class="block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.totp.code" }}</label>
<div class="mt-1">
<input id="code" name="code" type="text" required autocomplete="off" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
</div>
<button type="submit" class="mt-4 inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.totp.submit" }}</button>
</form>
</div>
</div>
{{ end }}
</main>
</div>
{{ template "footer" .}}