TOTP methods can now be created on the frontend!

This commit is contained in:
ari melody 2025-01-23 12:09:33 +00:00
parent e457e979ff
commit 50cbce92fc
Signed by: ari
GPG key ID: CF99829C92678188
11 changed files with 295 additions and 56 deletions

View file

@ -3,6 +3,7 @@ package admin
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"os" "os"
"time" "time"
@ -15,6 +16,10 @@ import (
func accountHandler(app *model.AppState) http.Handler { func accountHandler(app *model.AppState) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/totp-setup", totpSetupHandler(app))
mux.Handle("/totp-confirm", totpConfirmHandler(app))
mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
mux.Handle("/password", changePasswordHandler(app)) mux.Handle("/password", changePasswordHandler(app))
mux.Handle("/delete", deleteAccountHandler(app)) mux.Handle("/delete", deleteAccountHandler(app))
@ -169,3 +174,178 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
http.Redirect(w, r, "/admin/login", http.StatusFound) http.Redirect(w, r, "/admin/login", http.StatusFound)
}) })
} }
func totpSetupHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
type totpSetupData struct {
Session *model.Session
}
session := r.Context().Value("session").(*model.Session)
err := pages["totp-setup"].Execute(w, totpSetupData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
type totpSetupData struct {
Session *model.Session
TOTP *model.TOTP
NameEscaped string
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.FormValue("totp-name")
if len(name) == 0 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
session := r.Context().Value("session").(*model.Session)
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
totp := model.TOTP {
AccountID: session.Account.ID,
Name: name,
Secret: string(secret),
}
err = controller.CreateTOTP(app.DB, &totp)
if err != nil {
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
err := pages["totp-setup"].Execute(w, totpSetupData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
err = pages["totp-confirm"].Execute(w, totpSetupData{
Session: session,
TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name),
})
if err != nil {
fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func totpConfirmHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
type totpConfirmData struct {
Session *model.Session
TOTP *model.TOTP
}
session := r.Context().Value("session").(*model.Session)
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
name := r.FormValue("totp-name")
if len(name) == 0 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
code := r.FormValue("totp")
if len(code) != controller.TOTP_CODE_LENGTH {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
if totp == nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
if code != confirmCode {
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
if code != confirmCodeOffset {
controller.SetSessionError(app.DB, session, "Incorrect TOTP code. Please try again.")
err = pages["totp-confirm"].Execute(w, totpConfirmData{
Session: session,
TOTP: totp,
})
return
}
}
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
http.Redirect(w, r, "/admin/account", http.StatusFound)
})
}
func totpDeleteHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
name := r.URL.Path
fmt.Printf("%s\n", name);
if len(name) == 0 {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
session := r.Context().Value("session").(*model.Session)
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
if totp == nil {
http.NotFound(w, r)
return
}
err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name)
if err != nil {
fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
return
}
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
http.Redirect(w, r, "/admin/account", http.StatusFound)
})
}

View file

@ -93,8 +93,6 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
func registerAccountHandler(app *model.AppState) http.Handler { func registerAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session) session := r.Context().Value("session").(*model.Session)
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "")
if session.Account != nil { if session.Account != nil {
// user is already logged in // user is already logged in
@ -126,8 +124,7 @@ func registerAccountHandler(app *model.AppState) http.Handler {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
controller.SetSessionError(app.DB, session, "Malformed data.") http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
render()
return return
} }
@ -201,6 +198,8 @@ func registerAccountHandler(app *model.AppState) http.Handler {
// registration success! // registration success!
controller.SetSessionAccount(app.DB, session, &account) controller.SetSessionAccount(app.DB, session, &account)
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound) http.Redirect(w, r, "/admin", http.StatusFound)
}) })
} }
@ -240,8 +239,7 @@ func loginHandler(app *model.AppState) http.Handler {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
controller.SetSessionError(app.DB, session, "Malformed data.") http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
render()
return return
} }
@ -309,6 +307,8 @@ func loginHandler(app *model.AppState) http.Handler {
// login success! // login success!
controller.SetSessionAccount(app.DB, session, account) controller.SetSessionAccount(app.DB, session, account)
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound) http.Redirect(w, r, "/admin", http.StatusFound)
}) })
} }

View file

@ -146,7 +146,7 @@ a img.icon {
a.delete { a.delete:not(.button) {
color: #d22828; color: #d22828;
} }
@ -197,3 +197,30 @@ button:active, .button:active {
opacity: .5; opacity: .5;
cursor: not-allowed !important; cursor: not-allowed !important;
} }
form {
width: 100%;
display: block;
}
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input {
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
}
input[disabled] {
opacity: .5;
cursor: not-allowed;
}

View file

@ -4,10 +4,6 @@ div.card {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
form button {
margin-top: 1rem;
}
label { label {
width: 100%; width: 100%;
margin: 1rem 0 .5rem 0; margin: 1rem 0 .5rem 0;

View file

@ -33,6 +33,16 @@ var pages = map[string]*template.Template{
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-account.html"), filepath.Join("admin", "views", "edit-account.html"),
)), )),
"totp-setup": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "totp-setup.html"),
)),
"totp-confirm": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "totp-confirm.html"),
)),
"release": template.Must(template.ParseFiles( "release": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),

View file

@ -44,7 +44,7 @@
<p class="mfa-device-date">Added: {{.CreatedAt}}</p> <p class="mfa-device-date">Added: {{.CreatedAt}}</p>
</div> </div>
<div> <div>
<a class="delete">Delete</a> <a class="button delete" href="/admin/account/totp-delete/{{.Name}}">Delete</a>
</div> </div>
</div> </div>
{{end}} {{end}}

View file

@ -11,7 +11,7 @@ a.discord {
color: #5865F2; color: #5865F2;
} }
form { form#login {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -26,26 +26,8 @@ form button {
margin-top: 1rem; margin-top: 1rem;
} }
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input { input {
width: 100%; width: 100%;
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
}
input[disabled] {
opacity: .5;
cursor: not-allowed;
} }
</style> </style>
{{end}} {{end}}

View file

@ -11,7 +11,7 @@ a.discord {
color: #5865F2; color: #5865F2;
} }
form { form#register {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -26,22 +26,8 @@ form button {
margin-top: 1rem; margin-top: 1rem;
} }
label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
color: #10101080;
}
input { input {
width: 100%; width: 100%;
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
} }
</style> </style>
{{end}} {{end}}
@ -52,7 +38,7 @@ input {
<p id="error">{{html .Session.Error.String}}</p> <p id="error">{{html .Session.Error.String}}</p>
{{end}} {{end}}
<form action="/admin/register" method="POST" id="create-account"> <form action="/admin/register" method="POST" id="register">
<div> <div>
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username" required> <input type="text" name="username" value="" autocomplete="username" required>

View file

@ -0,0 +1,34 @@
{{define "head"}}
<title>TOTP Confirmation - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
code {
user-select: all;
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/account/totp-confirm?totp-name={{.NameEscaped}}" method="POST" id="totp-setup">
<p><strong>Your TOTP secret: </strong><code>{{.TOTP.Secret}}</code></p>
<!-- TODO: TOTP secret QR codes -->
<p>
Please store this into your two-factor authentication app or
password manager, then enter your code below:
</p>
<label for="totp">TOTP:</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
<button type="submit" class="new">Create</button>
</form>
</main>
{{end}}

View file

@ -0,0 +1,20 @@
{{define "head"}}
<title>TOTP Setup - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
{{end}}
{{define "content"}}
<main>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/account/totp-setup" method="POST" id="totp-setup">
<label for="totp-name">TOTP Device Name:</label>
<input type="text" name="totp-name" value="" autocomplete="off" required>
<button type="submit" class="new">Create</button>
</form>
</main>
{{end}}

View file

@ -17,9 +17,9 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
const TOTP_SECRET_LENGTH = 64 const TOTP_SECRET_LENGTH = 32
const TIME_STEP int64 = 30 const TOTP_TIME_STEP int64 = 30
const CODE_LENGTH = 6 const TOTP_CODE_LENGTH = 6
func GenerateTOTP(secret string, timeStepOffset int) string { func GenerateTOTP(secret string, timeStepOffset int) string {
decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
@ -27,7 +27,7 @@ func GenerateTOTP(secret string, timeStepOffset int) string {
fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n") fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n")
} }
counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset) counter := time.Now().Unix() / TOTP_TIME_STEP - int64(timeStepOffset)
counterBytes := make([]byte, 8) counterBytes := make([]byte, 8)
binary.BigEndian.PutUint64(counterBytes, uint64(counter)) binary.BigEndian.PutUint64(counterBytes, uint64(counter))
@ -37,9 +37,9 @@ func GenerateTOTP(secret string, timeStepOffset int) string {
offset := hash[len(hash) - 1] & 0x0f offset := hash[len(hash) - 1] & 0x0f
binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF) binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF)
code := binaryCode % int32(math.Pow10(CODE_LENGTH)) code := binaryCode % int32(math.Pow10(TOTP_CODE_LENGTH))
return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code) return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_CODE_LENGTH), code)
} }
func GenerateTOTPSecret(length int) string { func GenerateTOTPSecret(length int) string {
@ -65,8 +65,8 @@ func GenerateTOTPURI(username string, secret string) string {
query.Set("secret", secret) query.Set("secret", secret)
query.Set("issuer", "arimelody.me") query.Set("issuer", "arimelody.me")
query.Set("algorithm", "SHA1") query.Set("algorithm", "SHA1")
query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH)) query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
query.Set("period", fmt.Sprintf("%d", TIME_STEP)) query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))
url.RawQuery = query.Encode() url.RawQuery = query.Encode()
return url.String() return url.String()
@ -98,7 +98,11 @@ func CheckTOTPForAccount(db *sqlx.DB, accountID string, totp string) (*model.TOT
for _, method := range totps { for _, method := range totps {
check := GenerateTOTP(method.Secret, 0) check := GenerateTOTP(method.Secret, 0)
if check == totp { if check == totp {
// return the whole TOTP method as it may be useful for logging return &method, nil
}
// try again with offset- maybe user input the code late?
check = GenerateTOTP(method.Secret, 1)
if check == totp {
return &method, nil return &method, nil
} }
} }