mirror of
https://github.com/thomiceli/opengist.git
synced 2025-06-12 21:47:11 +02:00
Add TOTP MFA (#342)
This commit is contained in:
61
internal/auth/totp/totp.go
Normal file
61
internal/auth/totp/totp.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
121
internal/db/totp.go
Normal 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"`
|
||||
}
|
@ -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 ""
|
||||
}
|
||||
|
@ -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 -- //
|
||||
|
@ -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
46
internal/utils/aes.go
Normal 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
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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"))
|
||||
|
@ -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())
|
||||
}
|
||||
|
Reference in New Issue
Block a user