Files
warpbox-dev/backend/libs/handlers/auth.go
Daniel Legt 61b7c283a4 fix(auth): reject invalid bearer tokens instead of falling back
Modify the authentication handler to return an unauthorized error when
an invalid or disabled bearer token is provided, rather than silently
falling back to an anonymous request.

This ensures that clients attempting to authenticate but failing (due to
expired, malformed, or disabled tokens) are explicitly notified of the
auth failure instead of proceeding anonymously. True anonymous requests
without any Authorization header remain supported.
2026-05-31 13:02:58 +03:00

320 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) {
user, ok, _ := a.currentUserWithAuthError(r)
return user, ok
}
func (a *App) currentUserWithAuthError(r *http.Request) (services.User, bool, error) {
// 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 {
user, err := a.authService.UserForAPIToken(raw)
if err != nil {
return services.User{}, false, err
}
return user, true, nil
}
}
cookie, err := r.Cookie(userSessionCookieName)
if err != nil {
return services.User{}, false, nil
}
user, _, err := a.authService.UserForSession(cookie.Value)
if err != nil {
return services.User{}, false, nil
}
return user, true, 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
}