- Add backend services to create, list, and delete API tokens. - Implement Bearer token authentication to resolve tokens to users. - Register HTTP routes for managing user tokens under `/account/tokens`. - Add tests to verify that uploads with valid Bearer tokens associate the upload with the correct user, while invalid tokens fall back to anonymous uploads.
311 lines
10 KiB
Go
311 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"warpbox.dev/backend/libs/services"
|
|
"warpbox.dev/backend/libs/web"
|
|
)
|
|
|
|
const userSessionCookieName = "warpbox_session"
|
|
|
|
type authPageData struct {
|
|
Mode string
|
|
Token string
|
|
Email string
|
|
IsReset bool
|
|
Error string
|
|
ReturnPath string
|
|
}
|
|
|
|
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
|
available, err := a.authService.BootstrapAvailable()
|
|
if err != nil {
|
|
http.Error(w, "unable to check registration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !available {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "register"})
|
|
}
|
|
|
|
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
|
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
|
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
|
|
return
|
|
}
|
|
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
|
|
if err != nil {
|
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
|
|
return
|
|
}
|
|
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)
|
|
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
|
}
|
|
|
|
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := a.currentUser(r); ok {
|
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
|
return
|
|
}
|
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
|
|
}
|
|
|
|
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
|
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
|
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
|
|
return
|
|
}
|
|
next := r.FormValue("next")
|
|
if next == "" {
|
|
next = "/app"
|
|
}
|
|
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
|
|
if err != nil {
|
|
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))
|
|
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
|
|
return
|
|
}
|
|
a.setUserSessionCookie(w, r, token)
|
|
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)
|
|
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
|
if !a.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
|
_ = a.authService.Logout(cookie.Value)
|
|
}
|
|
a.clearUserSessionCookie(w)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
|
|
invite, err := a.authService.InviteByToken(r.PathValue("token"))
|
|
if err != nil || invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
|
|
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
|
return
|
|
}
|
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
|
|
}
|
|
|
|
func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
|
token := r.PathValue("token")
|
|
invite, err := a.authService.InviteByToken(token)
|
|
if err != nil {
|
|
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: "Unable to read form."})
|
|
return
|
|
}
|
|
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
|
|
if err != nil {
|
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
|
|
return
|
|
}
|
|
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID)
|
|
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
|
}
|
|
|
|
type apiTokenView struct {
|
|
ID string
|
|
Name string
|
|
CreatedAt string
|
|
LastUsedAt string
|
|
}
|
|
|
|
type accountData struct {
|
|
ID string
|
|
Email string
|
|
Role string
|
|
Tokens []apiTokenView
|
|
NewToken string
|
|
Error string
|
|
}
|
|
|
|
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
a.renderAccount(w, r, http.StatusOK, user, accountData{})
|
|
}
|
|
|
|
// CreateUserToken mints a new personal access token and renders the account
|
|
// page with the one-time plaintext shown. The secret is never recoverable after
|
|
// this response.
|
|
func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.requireUser(w, r)
|
|
if !ok || !a.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Unable to read form."})
|
|
return
|
|
}
|
|
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
|
|
if err != nil {
|
|
a.logger.Warn("api token create failed", "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())
|
|
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
|
|
return
|
|
}
|
|
a.logger.Info("api token created", "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)
|
|
a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
|
|
}
|
|
|
|
func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.requireUser(w, r)
|
|
if !ok || !a.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
|
|
a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())
|
|
}
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) renderAccount(w http.ResponseWriter, r *http.Request, status int, user services.User, data accountData) {
|
|
tokens, err := a.authService.ListAPITokens(user.ID)
|
|
if err != nil {
|
|
http.Error(w, "unable to load tokens", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
views := make([]apiTokenView, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
lastUsed := "Never"
|
|
if token.LastUsedAt != nil {
|
|
lastUsed = token.LastUsedAt.Format("Jan 2, 2006 15:04")
|
|
}
|
|
views = append(views, apiTokenView{
|
|
ID: token.ID,
|
|
Name: token.Name,
|
|
CreatedAt: token.CreatedAt.Format("Jan 2, 2006"),
|
|
LastUsedAt: lastUsed,
|
|
})
|
|
}
|
|
data.ID = user.ID
|
|
data.Email = user.Email
|
|
data.Role = user.Role
|
|
data.Tokens = views
|
|
|
|
a.renderPage(w, r, status, "account.html", web.PageData{
|
|
Title: "Account settings",
|
|
Description: "Manage your Warpbox account.",
|
|
CurrentUser: a.authService.PublicUser(user),
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.requireUser(w, r)
|
|
if !ok || !a.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) renderAuth(w http.ResponseWriter, r *http.Request, status int, data authPageData) {
|
|
a.renderPage(w, r, status, "auth.html", web.PageData{
|
|
Title: "Account",
|
|
Description: "Sign in to Warpbox.",
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, password, path string) {
|
|
_, token, err := a.authService.Login(email, password)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
a.setUserSessionCookie(w, r, token)
|
|
http.Redirect(w, r, path, http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) currentUser(r *http.Request) (services.User, bool) {
|
|
// Personal access tokens via Authorization: Bearer act as their owning user.
|
|
// A bearer header is never set by browsers cross-site, so this path is not
|
|
// subject to CSRF and intentionally bypasses the session cookie.
|
|
if header := r.Header.Get("Authorization"); header != "" {
|
|
if raw, ok := strings.CutPrefix(header, "Bearer "); ok {
|
|
if user, err := a.authService.UserForAPIToken(raw); err == nil {
|
|
return user, true
|
|
}
|
|
return services.User{}, false
|
|
}
|
|
}
|
|
cookie, err := r.Cookie(userSessionCookieName)
|
|
if err != nil {
|
|
return services.User{}, false
|
|
}
|
|
user, _, err := a.authService.UserForSession(cookie.Value)
|
|
return user, err == nil
|
|
}
|
|
|
|
func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
|
|
user, ok := a.currentUser(r)
|
|
if ok {
|
|
return user, true
|
|
}
|
|
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
|
|
return services.User{}, false
|
|
}
|
|
|
|
func (a *App) setUserSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: userSessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: r.TLS != nil,
|
|
Expires: time.Now().Add(30 * 24 * time.Hour),
|
|
})
|
|
}
|
|
|
|
func (a *App) clearUserSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: userSessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
func safeReturnPath(path string) string {
|
|
if path == "" || path[0] != '/' || len(path) > 1 && path[1] == '/' {
|
|
return "/app"
|
|
}
|
|
return path
|
|
}
|