Add passkeys support + MFA (#341)

This commit is contained in:
Thomas Miceli
2024-10-07 23:56:32 +02:00
committed by GitHub
parent 41dc2e451b
commit 6959929094
20 changed files with 1073 additions and 105 deletions

View File

@ -1,9 +1,10 @@
package web
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
gojson "encoding/json"
"errors"
"fmt"
"github.com/labstack/echo/v4"
@ -14,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/webauthn"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
@ -166,6 +168,17 @@ func processLogin(ctx echo.Context) error {
return redirect(ctx, "/login")
}
// handle MFA
var hasMFA bool
if hasMFA, err = user.HasMFA(); err != nil {
return errorRes(500, "Cannot check for user MFA", err)
}
if hasMFA {
sess.Values["mfaID"] = user.ID
saveSession(sess, ctx)
return redirect(ctx, "/mfa")
}
sess.Values["user"] = user.ID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
saveSession(sess, ctx)
@ -174,6 +187,10 @@ func processLogin(ctx echo.Context) error {
return redirect(ctx, "/")
}
func mfa(ctx echo.Context) error {
return html(ctx, "mfa.html")
}
func oauthCallback(ctx echo.Context) error {
user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request())
if err != nil {
@ -376,6 +393,147 @@ func oauthUnlink(ctx echo.Context) error {
return redirect(ctx, "/settings")
}
func beginWebAuthnBinding(ctx echo.Context) error {
credsCreation, jsonWaSession, err := webauthn.BeginBinding(getUserLogged(ctx))
if err != nil {
return errorRes(500, "Cannot begin WebAuthn registration", err)
}
sess := getSession(ctx)
sess.Values["webauthn_registration_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return ctx.JSON(200, credsCreation)
}
func finishWebAuthnBinding(ctx echo.Context) error {
sess := getSession(ctx)
jsonWaSession, ok := sess.Values["webauthn_registration_session"].([]byte)
if !ok {
return jsonErrorRes(401, "Cannot get WebAuthn registration session", nil)
}
user := getUserLogged(ctx)
// extract passkey name from request
body, err := io.ReadAll(ctx.Request().Body)
if err != nil {
return jsonErrorRes(400, "Failed to read request body", err)
}
ctx.Request().Body.Close()
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(body))
dto := new(db.CrendentialDTO)
_ = gojson.Unmarshal(body, &dto)
if err = ctx.Validate(dto); err != nil {
return jsonErrorRes(400, "Invalid request", err)
}
passkeyName := dto.PasskeyName
if passkeyName == "" {
passkeyName = "WebAuthn"
}
waCredential, err := webauthn.FinishBinding(user, jsonWaSession, ctx.Request())
if err != nil {
return jsonErrorRes(403, "Failed binding attempt for passkey", err)
}
if _, err = db.CreateFromCrendential(user.ID, passkeyName, waCredential); err != nil {
return jsonErrorRes(500, "Cannot create WebAuthn credential on database", err)
}
delete(sess.Values, "webauthn_registration_session")
saveSession(sess, ctx)
addFlash(ctx, tr(ctx, "flash.auth.passkey-registred", passkeyName), "success")
return json(ctx, 200, []string{"OK"})
}
func beginWebAuthnLogin(ctx echo.Context) error {
credsCreation, jsonWaSession, err := webauthn.BeginDiscoverableLogin()
if err != nil {
return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
}
sess := getSession(ctx)
sess.Values["webauthn_login_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return json(ctx, 200, credsCreation)
}
func finishWebAuthnLogin(ctx echo.Context) error {
sess := getSession(ctx)
sessionData, ok := sess.Values["webauthn_login_session"].([]byte)
if !ok {
return jsonErrorRes(401, "Cannot get WebAuthn login session", nil)
}
userID, err := webauthn.FinishDiscoverableLogin(sessionData, ctx.Request())
if err != nil {
return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
}
sess.Values["user"] = userID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "webauthn_login_session")
saveSession(sess, ctx)
return json(ctx, 200, []string{"OK"})
}
func beginWebAuthnAssertion(ctx echo.Context) error {
sess := getSession(ctx)
ogUser, err := db.GetUserById(sess.Values["mfaID"].(uint))
if err != nil {
return jsonErrorRes(500, "Cannot get user", err)
}
credsCreation, jsonWaSession, err := webauthn.BeginLogin(ogUser)
if err != nil {
return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
}
sess.Values["webauthn_assertion_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return json(ctx, 200, credsCreation)
}
func finishWebAuthnAssertion(ctx echo.Context) error {
sess := getSession(ctx)
sessionData, ok := sess.Values["webauthn_assertion_session"].([]byte)
if !ok {
return jsonErrorRes(401, "Cannot get WebAuthn assertion session", nil)
}
userId := sess.Values["mfaID"].(uint)
ogUser, err := db.GetUserById(userId)
if err != nil {
return jsonErrorRes(500, "Cannot get user", err)
}
if err = webauthn.FinishLogin(ogUser, sessionData, ctx.Request()); err != nil {
return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
}
sess.Values["user"] = userId
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "webauthn_assertion_session")
delete(sess.Values, "mfaID")
saveSession(sess, ctx)
return json(ctx, 200, []string{"OK"})
}
func logout(ctx echo.Context) error {
deleteSession(ctx)
deleteCsrfCookie(ctx)
@ -427,7 +585,7 @@ func getAvatarUrlFromProvider(provider string, identifier string) string {
}
var result map[string]interface{}
err = json.Unmarshal(body, &result)
err = gojson.Unmarshal(body, &result)
if err != nil {
log.Error().Err(err).Msg("Cannot unmarshal Gitea response body")
return ""

View File

@ -2,7 +2,7 @@ package web
import (
"context"
"encoding/json"
gojson "encoding/json"
"errors"
"fmt"
htmlpkg "html"
@ -215,10 +215,14 @@ func NewServer(isDev bool, sessionsPath string) *Server {
}
e.HTTPErrorHandler = func(er error, ctx echo.Context) {
if err, ok := er.(*echo.HTTPError); ok {
setData(ctx, "error", err)
if errHtml := htmlWithCode(ctx, err.Code, "error.html"); errHtml != nil {
log.Fatal().Err(errHtml).Send()
if httpErr, ok := er.(*HTMLError); ok {
setData(ctx, "error", er)
if fatalErr := htmlWithCode(ctx, httpErr.Code, "error.html"); fatalErr != nil {
log.Fatal().Err(fatalErr).Send()
}
} else if httpErr, ok := er.(*JSONError); ok {
if fatalErr := json(ctx, httpErr.Code, httpErr); fatalErr != nil {
log.Fatal().Err(fatalErr).Send()
}
} else {
log.Fatal().Err(er).Send()
@ -238,14 +242,13 @@ func NewServer(isDev bool, sessionsPath string) *Server {
{
if !dev {
g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "form:_csrf",
TokenLookup: "form:_csrf,header:X-CSRF-Token",
CookiePath: "/",
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
}))
g1.Use(csrfInit)
}
g1.Use(csrfInit)
g1.GET("/", create, logged)
g1.POST("/", processCreate, logged)
g1.GET("/preview", preview, logged)
@ -261,12 +264,20 @@ func NewServer(isDev bool, sessionsPath string) *Server {
g1.GET("/oauth/:provider", oauth)
g1.GET("/oauth/:provider/callback", oauthCallback)
g1.GET("/oauth/:provider/unlink", oauthUnlink, logged)
g1.POST("/webauthn/bind", beginWebAuthnBinding, logged)
g1.POST("/webauthn/bind/finish", finishWebAuthnBinding, logged)
g1.POST("/webauthn/login", beginWebAuthnLogin)
g1.POST("/webauthn/login/finish", finishWebAuthnLogin)
g1.POST("/webauthn/assertion", beginWebAuthnAssertion, inMFASession)
g1.POST("/webauthn/assertion/finish", finishWebAuthnAssertion, inMFASession)
g1.GET("/mfa", mfa, inMFASession)
g1.GET("/settings", userSettings, logged)
g1.POST("/settings/email", emailProcess, logged)
g1.DELETE("/settings/account", accountDeleteProcess, logged)
g1.POST("/settings/ssh-keys", sshKeysProcess, logged)
g1.DELETE("/settings/ssh-keys/:id", sshKeysDelete, logged)
g1.DELETE("/settings/passkeys/:id", passkeyDelete, logged)
g1.PUT("/settings/password", passwordProcess, logged)
g1.PUT("/settings/username", usernameProcess, logged)
g2 := g1.Group("/admin-panel")
@ -518,6 +529,17 @@ func logged(next echo.HandlerFunc) echo.HandlerFunc {
}
}
func inMFASession(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
sess := getSession(ctx)
_, ok := sess.Values["mfaID"].(uint)
if !ok {
return errorRes(400, tr(ctx, "error.not-in-mfa-session"), nil)
}
return next(ctx)
}
}
func makeCheckRequireLogin(isSingleGistAccess bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
@ -564,7 +586,7 @@ func parseManifestEntries() {
if err != nil {
log.Fatal().Err(err).Msg("Failed to read manifest.json")
}
if err = json.Unmarshal(byteValue, &manifestEntries); err != nil {
if err = gojson.Unmarshal(byteValue, &manifestEntries); err != nil {
log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json")
}
}

View File

@ -26,8 +26,14 @@ func userSettings(ctx echo.Context) error {
return errorRes(500, "Cannot get SSH keys", err)
}
passkeys, err := db.GetAllCredentialsForUser(user.ID)
if err != nil {
return errorRes(500, "Cannot get WebAuthn credentials", err)
}
setData(ctx, "email", user.Email)
setData(ctx, "sshKeys", keys)
setData(ctx, "passkeys", passkeys)
setData(ctx, "hasPassword", user.Password != "")
setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
setData(ctx, "htmlTitle", trH(ctx, "settings"))
@ -127,6 +133,26 @@ func sshKeysDelete(ctx echo.Context) error {
return redirect(ctx, "/settings")
}
func passkeyDelete(ctx echo.Context) error {
user := getUserLogged(ctx)
keyId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
return redirect(ctx, "/settings")
}
passkey, err := db.GetCredentialByIDDB(uint(keyId))
if err != nil || passkey.UserID != user.ID {
return redirect(ctx, "/settings")
}
if err := passkey.Delete(); err != nil {
return errorRes(500, "Cannot delete passkey", err)
}
addFlash(ctx, tr(ctx, "flash.auth.passkey-deleted"), "success")
return redirect(ctx, "/settings")
}
func passwordProcess(ctx echo.Context) error {
user := getUserLogged(ctx)

View File

@ -19,6 +19,14 @@ import (
type dataTypeKey string
type HTMLError struct {
*echo.HTTPError
}
type JSONError struct {
*echo.HTTPError
}
const dataKey dataTypeKey = "data"
func setData(ctx echo.Context, key string, value any) {
@ -46,6 +54,10 @@ func htmlWithCode(ctx echo.Context, code int, template string) error {
return ctx.Render(code, template, ctx.Request().Context().Value(dataKey))
}
func json(ctx echo.Context, code int, data any) error {
return ctx.JSON(code, data)
}
func redirect(ctx echo.Context, location string) error {
return ctx.Redirect(302, config.C.ExternalUrl+location)
}
@ -64,7 +76,16 @@ func errorRes(code int, message string, err error) error {
skipLogger.Error().Err(err).Msg(message)
}
return &echo.HTTPError{Code: code, Message: message, Internal: err}
return &HTMLError{&echo.HTTPError{Code: code, Message: message, Internal: err}}
}
func jsonErrorRes(code int, message string, err error) error {
if code >= 500 {
var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
skipLogger.Error().Err(err).Msg(message)
}
return &JSONError{&echo.HTTPError{Code: code, Message: message, Internal: err}}
}
func getUserLogged(ctx echo.Context) *db.User {
@ -102,14 +123,15 @@ func saveSession(sess *sessions.Session, ctx echo.Context) {
func deleteSession(ctx echo.Context) {
sess := getSession(ctx)
sess.Options.MaxAge = -1
sess.Values["user"] = nil
saveSession(sess, ctx)
}
func setCsrfHtmlForm(ctx echo.Context) {
var csrf string
if csrfToken, ok := ctx.Get("csrf").(string); ok {
setData(ctx, "csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrfToken+`">`))
csrf = csrfToken
}
setData(ctx, "csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
}
func deleteCsrfCookie(ctx echo.Context) {