نسخ من khaledmahfouz5/Maqtaa
Add TOTP MFA (#342)
هذا الالتزام موجود في:
@@ -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())
|
||||
}
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم