WIP: Add Two Factor Authentication #7

Draft
adoralaura wants to merge 11 commits from feature-2fa into main
8 changed files with 143 additions and 19 deletions
Showing only changes of commit b4707e200f - Show all commits

View file

@ -12,6 +12,7 @@ func addWebRoutes(f *fiber.App) {
f.Get("/admin/account/", web.HandleAdminAccountGet) f.Get("/admin/account/", web.HandleAdminAccountGet)
f.Get("/admin/account/mfasetup", web.HandleAdminAccountMFASetupGet) f.Get("/admin/account/mfasetup", web.HandleAdminAccountMFASetupGet)
f.Post("/admin/account/mfasetup", web.HandleAdminAccountMFASetupPost) f.Post("/admin/account/mfasetup", web.HandleAdminAccountMFASetupPost)
f.Delete("/admin/account/mfa", web.HandleAdminAccountMFARemove)
f.Get("/admin/login", web.HandleAdminLoginGet) f.Get("/admin/login", web.HandleAdminLoginGet)
f.Post("/admin/login", web.HandleAdminLoginPost) f.Post("/admin/login", web.HandleAdminLoginPost)

View file

@ -3,10 +3,14 @@ package db
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"code.lila.network/adoralaura/go-urlsh/models" "code.lila.network/adoralaura/go-urlsh/models"
) )
// UserHasMFA checks the DB if given models.User has MFA enabled.
// Returns (true, nil) if User has MFA enabled, (false, nil) if not.
// (false, error) if a DB error happened
func UserHasMFA(user models.User) (bool, error) { func UserHasMFA(user models.User) (bool, error) {
numrows, err := models.DB.NewSelect().Model((*models.MFAConfig)(nil)).Where("username = ?", user.UserName).Where("active = ?", true).Count(context.Background()) numrows, err := models.DB.NewSelect().Model((*models.MFAConfig)(nil)).Where("username = ?", user.UserName).Where("active = ?", true).Count(context.Background())
if err != nil { if err != nil {
@ -33,3 +37,30 @@ func ScratchCodeIsUnique(scratchcode string) bool {
} }
return true return true
} }
// RemoveMFAFromDB removes MFA entries for given models.User from the database.
// Returns nil on success, error otherwise.
func RemoveMFAFromDB(user models.User) error {
hasMfa, err := UserHasMFA(user)
if err != nil {
return fmt.Errorf("[RemoveMFAFromDB] Error removing MFA from DB for user %v: %w", user.UserName, err)
}
if !hasMfa {
return nil
}
_, err = models.DB.NewDelete().Model((*models.MFAConfig)(nil)).Where("username = ?", user.UserName).Exec(context.Background())
if err != nil {
log.Println(err.Error())
return fmt.Errorf("[RemoveMFAFromDB] Error removing MFA Config from DB for user %v: %w", user.UserName, err)
}
_, err = models.DB.NewDelete().Model((*models.MFAScratchCode)(nil)).Where("username = ?", user.UserName).Exec(context.Background())
if err != nil {
log.Println(err.Error())
return fmt.Errorf("[RemoveMFAFromDB] Error removing MFA scratch codes from DB for user %v: %w", user.UserName, err)
}
return nil
}

View file

@ -0,0 +1,49 @@
package web
import (
"log"
"net/http"
"code.lila.network/adoralaura/go-urlsh/internal/constants"
"code.lila.network/adoralaura/go-urlsh/internal/db"
"code.lila.network/adoralaura/go-urlsh/internal/misc"
"github.com/gofiber/fiber/v2"
)
// HandleAdminAccountMFARemove is a DELETE endpoint that handles the deletion
// of the logged in users MFA configuration.
//
// Returns HTTP 401 if no valid user cookie, HTTP 400 if no MFA is configured for the user,
// HTTP 500 if a DB error happened or HTTP 204 if the deletion request succeeded.
func HandleAdminAccountMFARemove(c *fiber.Ctx) error {
if !db.IsCookieValid(c.Cookies(constants.LoginCookieName, "")) {
c.Status(http.StatusUnauthorized)
return nil
}
user, err := db.GetUserFromCookie(c.Cookies(constants.LoginCookieName))
if err != nil {
log.Println(err)
return fiber.NewError(fiber.StatusInternalServerError, "500 Internal Server Error")
}
hasMfa, err := db.UserHasMFA(user)
if err != nil {
log.Println(err)
return fiber.NewError(fiber.StatusInternalServerError, "500 Internal Server Error")
}
if !hasMfa {
return misc.New400Error()
}
err = db.RemoveMFAFromDB(user)
if err != nil {
log.Println(err)
return fiber.NewError(fiber.StatusInternalServerError, "500 Internal Server Error")
}
c.Status(fiber.StatusNoContent)
return nil
}

View file

@ -11,9 +11,15 @@
<link rel="stylesheet" href="/admin/custom.css"> <link rel="stylesheet" href="/admin/custom.css">
<script src="/admin/main.js" defer></script> <script src="/admin/main.js" defer></script>
<style> <style>
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
margin-bottom: 30px; margin-bottom: 30px;
} }
a[role=button] { a[role=button] {
width: fit-content; width: fit-content;
} }
@ -52,16 +58,22 @@
<h2>Security</h2> <h2>Security</h2>
{{if .MFAEnabled}} {{if .MFAEnabled}}
<a role="button" class="secondary">2fa already enabled</a> <a href="#" onclick="HandleMFARemovalRequest()" role="button">disable Two-Factor Authentication</a>
{{else}} {{else}}
<a href="/admin/account/mfasetup" role="button">Setup 2fa</a> <a href="/admin/account/mfasetup" role="button">Setup Two-Factor Authentication</a>
{{end}} {{end}}
<dialog id="dialog-info"> <dialog id="mfa-disable-dialog" style="flex-direction: column;">
<h2 id="dialog-heading"></h2> <h2 id="dialog-heading">⚠️ Are you sure you want to disable two-factor authentication?</h2>
<p id="dialog-text"></p> <p id="dialog-text">Two-factor authentication adds an additional layer of security to your account by requiring
<form method="dialog"> more than just a password to sign in.<br />If you need to change your configuration, you can delete and re-add
<button>Close</button> Two-Factor authentication again.<br /><br />Do you really want to remove your Two-Factor authentication?</p>
</form> <div class="container grid">
<!-- change those two buttons and rework the functions to close and initiate deletion-->
<button id="button-keep" class="pico-background-green-450" onclick="HandleModalClose('mfa-disable-dialog')">No, keep Two-Factor authentication</button>
<button id="button-delete" class="pico-color-red-450 pico-background-red-450" onclick="HandleMFARemoval()">Yes,
remove Two-Factor authentication</button>
</div>
</dialog> </dialog>
</main> </main>
{{template "partials/footer" .}} {{template "partials/footer" .}}

View file

@ -72,19 +72,19 @@
<input type="submit" value="Submit" class="contrast" style="width: fit-content;"> <input type="submit" value="Submit" class="contrast" style="width: fit-content;">
</div> </div>
</form> </form>
<dialog id="dialog-success" style="flex-direction: column;"> <dialog id="user-dialog" style="flex-direction: column;">
<h2 id="dialog-heading">2FA successfully set up!</h2> <h2 id="dialog-heading">2FA successfully set up!</h2>
<p id="dialog-text">These are your recovery codes, keep them somewhere safe, you'll only see them once:</p> <p id="dialog-text">These are your recovery codes, keep them somewhere safe, you'll only see them once:</p>
<p id="dialog-tokens" class="monospace"></p> <p id="dialog-tokens" class="monospace"></p>
<form method="dialog"> <form method="dialog">
<button onclick="HandleModalClose('/admin/account')">Close</button> <button onclick="HandleModalClose('user-dialog', '/admin/account')">Close</button>
</form> </form>
</dialog> </dialog>
<dialog id="dialog-error" style="flex-direction: column;"> <dialog id="dialog-error" style="flex-direction: column;">
<h2 id="dialog-heading"></h2> <h2 id="dialog-heading"></h2>
<p id="dialog-text"></p> <p id="dialog-text"></p>
<form method="dialog"> <form method="dialog">
<button onclick="HandleModalClose()">Close</button> <button onclick="HandleModalClose('dialog-error')">Close</button>
</form> </form>
</dialog> </dialog>
</main> </main>

View file

@ -9,7 +9,6 @@
<link rel="stylesheet" href="/admin/pico.min.css"> <link rel="stylesheet" href="/admin/pico.min.css">
<link rel="stylesheet" href="/admin/pico.colors.min.css"> <link rel="stylesheet" href="/admin/pico.colors.min.css">
<link rel="stylesheet" href="/admin/custom.css"> <link rel="stylesheet" href="/admin/custom.css">
<script src="/admin/link_add.js" defer></script>
<script src="/admin/misc.js" defer></script> <script src="/admin/misc.js" defer></script>
</head> </head>

View file

@ -145,6 +145,39 @@ async function HandleMFASetupTokenSubmit() {
} }
} }
async function HandleMFARemovalRequest() {
document.getElementById('mfa-disable-dialog').showModal()
}
async function HandleMFARemoval() {
let keepButton = document.getElementById('button-keep')
let deleteButton = document.getElementById('button-delete')
keepButton.disabled = true
deleteButton.disabled = true
deleteButton.value = 'Please wait...'
deleteButton.setAttribute('aria-busy', 'true')
endpoint = "/admin/account/mfa"
let response = await fetch(endpoint, {
credentials: "include",
mode: "same-origin",
method: "DELETE"
});
if (response.ok) {
document.location = "/admin/account"
} //else {
// TODO
// Handle error condition
//document.cookie = 'gourlsh_mfa_setup=; Max-Age=-1; path=/; domain=' + location.hostname;
//document.getElementById("dialog-heading").textContent = "Error!"
//document.getElementById("dialog-text").textContent = "Something didnt work!"
//document.getElementById('dialog-error').showModal()
//}
}
async function HandleMFALoginTokenPost() { async function HandleMFALoginTokenPost() {
document.getElementById("submit").disabled = true; document.getElementById("submit").disabled = true;
document.getElementById("token").disabled = true; document.getElementById("token").disabled = true;
@ -174,9 +207,8 @@ async function HandleMFALoginTokenPost() {
} }
} }
function HandleModalClose(redir) { function HandleModalClose(id, redir) {
document.getElementById('dialog-success').close(); document.getElementById(id).close();
document.getElementById('dialog-error').close();
if (redir) { if (redir) {
document.location = redir document.location = redir