mirror of
https://github.com/thomiceli/opengist.git
synced 2024-12-22 20:42:40 +00:00
Add TOTP MFA (#342)
This commit is contained in:
parent
df226cbd99
commit
2bf434f00e
20 changed files with 629 additions and 16 deletions
2
go.mod
2
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
|
||||
|
|
5
go.sum
5
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=
|
||||
|
|
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())
|
||||
}
|
||||
|
|
1
public/tailwind.config.js
vendored
1
public/tailwind.config.js
vendored
|
@ -11,6 +11,7 @@ module.exports = {
|
|||
current: 'currentColor',
|
||||
white: colors.white,
|
||||
black: colors.black,
|
||||
yellow: colors.yellow,
|
||||
gray: {
|
||||
50: "#EEEFF1",
|
||||
100: "#DEDFE3",
|
||||
|
|
14
templates/base/base_header.html
vendored
14
templates/base/base_header.html
vendored
|
@ -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 }}
|
||||
|
|
25
templates/pages/mfa.html
vendored
25
templates/pages/mfa.html
vendored
|
@ -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>
|
||||
|
|
32
templates/pages/settings.html
vendored
32
templates/pages/settings.html
vendored
|
@ -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
54
templates/pages/totp.html
vendored
Normal 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" .}}
|
Loading…
Reference in a new issue