2026-05-30 15:42:35 +03:00
package handlers
import (
"net/http"
2026-05-31 12:50:13 +03:00
"strings"
2026-05-30 15:42:35 +03:00
"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
}
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusOK , authPageData { Mode : "register" } )
2026-05-30 15:42:35 +03:00
}
func ( a * App ) RegisterPost ( w http . ResponseWriter , r * http . Request ) {
2026-05-31 02:14:10 +03:00
if ! a . rateLimiter . Allow ( "register:" + uploadClientIP ( r ) , 10 , time . Minute , time . Now ( ) . UTC ( ) ) {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "registration rate limited" , withRequestLogAttrs ( r , "source" , "auth" , "severity" , "warn" , "code" , 4291 ) ... )
2026-05-31 02:14:10 +03:00
a . renderAuth ( w , r , http . StatusTooManyRequests , authPageData { Mode : "register" , Error : "Too many registration attempts." } )
return
}
2026-05-30 15:42:35 +03:00
if err := r . ParseForm ( ) ; err != nil {
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusBadRequest , authPageData { Mode : "register" , Error : "Unable to read form." } )
2026-05-30 15:42:35 +03:00
return
}
user , err := a . authService . CreateBootstrapUser ( r . FormValue ( "username" ) , r . FormValue ( "email" ) , r . FormValue ( "password" ) )
if err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "bootstrap registration failed" , withRequestLogAttrs ( r , "source" , "auth" , "severity" , "warn" , "code" , 4400 , "email" , r . FormValue ( "email" ) , "error" , err . Error ( ) ) ... )
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusBadRequest , authPageData { Mode : "register" , Error : err . Error ( ) } )
2026-05-30 15:42:35 +03:00
return
}
2026-06-01 11:30:38 +03:00
a . logger . Info ( "first admin created" , withRequestLogAttrs ( r , "source" , "auth" , "severity" , "user_activity" , "code" , 2401 , "user_id" , user . ID ) ... )
2026-05-30 15:42:35 +03:00
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
}
2026-06-01 11:30:38 +03:00
a . logger . Info ( "login page viewed" , withRequestLogAttrs ( r , "source" , "page" , "severity" , "user_activity" , "code" , 2503 , "actor" , "anonymous" ) ... )
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusOK , authPageData { Mode : "login" , ReturnPath : r . URL . Query ( ) . Get ( "next" ) } )
2026-05-30 15:42:35 +03:00
}
func ( a * App ) LoginPost ( w http . ResponseWriter , r * http . Request ) {
2026-05-31 02:14:10 +03:00
if ! a . rateLimiter . Allow ( "login:" + uploadClientIP ( r ) , 10 , time . Minute , time . Now ( ) . UTC ( ) ) {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "login rate limited" , withRequestLogAttrs ( r , "source" , "auth" , "severity" , "warn" , "code" , 4292 , "email" , r . FormValue ( "email" ) ) ... )
2026-05-31 02:14:10 +03:00
a . renderAuth ( w , r , http . StatusTooManyRequests , authPageData { Mode : "login" , Error : "Too many login attempts." } )
return
}
2026-05-30 15:42:35 +03:00
if err := r . ParseForm ( ) ; err != nil {
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusBadRequest , authPageData { Mode : "login" , Error : "Unable to read form." } )
2026-05-30 15:42:35 +03:00
return
}
next := r . FormValue ( "next" )
if next == "" {
next = "/app"
}
user , token , err := a . authService . Login ( r . FormValue ( "email" ) , r . FormValue ( "password" ) )
if err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "login failed" , withRequestLogAttrs ( r , "source" , "auth" , "severity" , "warn" , "code" , 4401 , "email" , r . FormValue ( "email" ) ) ... )
2026-05-31 21:52:56 +03:00
a . recordLoginAbuse ( r , services . AbuseKindUserLogin , "user login failed" )
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusUnauthorized , authPageData { Mode : "login" , Error : "Invalid email or password." , ReturnPath : next } )
2026-05-30 15:42:35 +03:00
return
}
a . setUserSessionCookie ( w , r , token )
2026-06-01 11:30:38 +03:00
a . logger . Info ( "user login" , withRequestLogAttrs ( r , "source" , "auth" , "severity" , "user_activity" , "code" , 2402 , "user_id" , user . ID ) ... )
2026-05-30 15:42:35 +03:00
http . Redirect ( w , r , safeReturnPath ( next ) , http . StatusSeeOther )
}
func ( a * App ) Logout ( w http . ResponseWriter , r * http . Request ) {
2026-05-31 02:14:10 +03:00
if ! a . validateCSRF ( w , r ) {
return
}
2026-05-31 21:52:56 +03:00
if user , ok := a . currentUser ( r ) ; ok {
2026-06-01 11:30:38 +03:00
a . logger . Info ( "user logout" , withRequestLogAttrs ( r , "source" , "auth" , "severity" , "user_activity" , "code" , 2405 , "user_id" , user . ID ) ... )
2026-05-31 21:52:56 +03:00
}
2026-05-30 15:42:35 +03:00
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 ) {
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusNotFound , authPageData { Mode : "invite" , Error : "This invite is invalid or expired." } )
2026-05-30 15:42:35 +03:00
return
}
2026-06-01 11:30:38 +03:00
a . logger . Info ( "invite page viewed" , withRequestLogAttrs ( r , "source" , "page" , "severity" , "user_activity" , "code" , 2504 , "invite_email" , invite . Email , "reset" , invite . UserID != "" ) ... )
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusOK , authPageData { Mode : "invite" , Token : r . PathValue ( "token" ) , Email : invite . Email , IsReset : invite . UserID != "" } )
2026-05-30 15:42:35 +03:00
}
func ( a * App ) InvitePost ( w http . ResponseWriter , r * http . Request ) {
token := r . PathValue ( "token" )
invite , err := a . authService . InviteByToken ( token )
if err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "invite accept invalid" , withRequestLogAttrs ( r , "source" , "auth" , "severity" , "warn" , "code" , 4404 ) ... )
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusNotFound , authPageData { Mode : "invite" , Error : "This invite is invalid or expired." } )
2026-05-30 15:42:35 +03:00
return
}
if err := r . ParseForm ( ) ; err != nil {
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusBadRequest , authPageData { Mode : "invite" , Token : token , Email : invite . Email , IsReset : invite . UserID != "" , Error : "Unable to read form." } )
2026-05-30 15:42:35 +03:00
return
}
user , err := a . authService . AcceptInvite ( token , r . FormValue ( "username" ) , r . FormValue ( "password" ) )
if err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "invite accept failed" , withRequestLogAttrs ( r , "source" , "auth" , "severity" , "warn" , "code" , 4405 , "invite_email" , invite . Email , "error" , err . Error ( ) ) ... )
2026-05-30 17:23:20 +03:00
a . renderAuth ( w , r , http . StatusBadRequest , authPageData { Mode : "invite" , Token : token , Email : invite . Email , IsReset : invite . UserID != "" , Error : err . Error ( ) } )
2026-05-30 15:42:35 +03:00
return
}
2026-06-01 11:30:38 +03:00
a . logger . Info ( "invite accepted" , withRequestLogAttrs ( r , "source" , "auth" , "severity" , "user_activity" , "code" , 2403 , "user_id" , user . ID , "invite_email" , invite . Email ) ... )
2026-05-30 15:42:35 +03:00
a . loginAndRedirect ( w , r , user . Email , r . FormValue ( "password" ) , "/app" )
}
2026-05-31 12:50:13 +03:00
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
}
2026-05-30 15:42:35 +03:00
func ( a * App ) AccountSettings ( w http . ResponseWriter , r * http . Request ) {
user , ok := a . requireUser ( w , r )
if ! ok {
return
}
2026-06-01 11:30:38 +03:00
a . logger . Info ( "account settings viewed" , withRequestLogAttrs ( r , "source" , "page" , "severity" , "user_activity" , "code" , 2505 , "user_id" , user . ID ) ... )
2026-05-31 12:50:13 +03:00
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 {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "api token create failed" , withRequestLogAttrs ( r , "source" , "user_activity" , "severity" , "warn" , "code" , 4420 , "user_id" , user . ID , "error" , err . Error ( ) ) ... )
2026-05-31 12:50:13 +03:00
a . renderAccount ( w , r , http . StatusBadRequest , user , accountData { Error : "Could not create token." } )
return
}
2026-06-01 11:30:38 +03:00
a . logger . Info ( "api token created" , withRequestLogAttrs ( r , "source" , "user_activity" , "severity" , "user_activity" , "code" , 2420 , "user_id" , user . ID , "token_id" , result . Token . ID ) ... )
2026-05-31 12:50:13 +03:00
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 {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "api token delete failed" , withRequestLogAttrs ( r , "source" , "user_activity" , "severity" , "warn" , "code" , 4421 , "user_id" , user . ID , "error" , err . Error ( ) ) ... )
2026-05-31 21:52:56 +03:00
} else {
2026-06-01 11:30:38 +03:00
a . logger . Info ( "api token deleted" , withRequestLogAttrs ( r , "source" , "user_activity" , "severity" , "user_activity" , "code" , 2421 , "user_id" , user . ID , "token_id" , r . PathValue ( "tokenID" ) ) ... )
2026-05-31 12:50:13 +03:00
}
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 {
2026-05-30 15:42:35 +03:00
Title : "Account settings" ,
Description : "Manage your Warpbox account." ,
CurrentUser : a . authService . PublicUser ( user ) ,
2026-05-31 12:50:13 +03:00
Data : data ,
2026-05-30 15:42:35 +03:00
} )
}
func ( a * App ) ChangePassword ( w http . ResponseWriter , r * http . Request ) {
user , ok := a . requireUser ( w , r )
2026-05-31 02:14:10 +03:00
if ! ok || ! a . validateCSRF ( w , r ) {
2026-05-30 15:42:35 +03:00
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" ) ) {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "password change failed current password" , withRequestLogAttrs ( r , "source" , "user_activity" , "severity" , "warn" , "code" , 4422 , "user_id" , user . ID ) ... )
2026-05-30 15:42:35 +03:00
http . Redirect ( w , r , "/account/settings" , http . StatusSeeOther )
return
}
if err := a . authService . SetPassword ( user . ID , r . FormValue ( "new_password" ) ) ; err != nil {
2026-06-01 11:30:38 +03:00
a . logger . Warn ( "password change failed" , withRequestLogAttrs ( r , "source" , "user_activity" , "severity" , "warn" , "code" , 4423 , "user_id" , user . ID , "error" , err . Error ( ) ) ... )
2026-05-30 15:42:35 +03:00
http . Redirect ( w , r , "/account/settings" , http . StatusSeeOther )
return
}
2026-06-01 11:30:38 +03:00
a . logger . Info ( "password changed" , withRequestLogAttrs ( r , "source" , "user_activity" , "severity" , "user_activity" , "code" , 2422 , "user_id" , user . ID ) ... )
2026-05-30 15:42:35 +03:00
http . Redirect ( w , r , "/account/settings" , http . StatusSeeOther )
}
2026-05-30 17:23:20 +03:00
func ( a * App ) renderAuth ( w http . ResponseWriter , r * http . Request , status int , data authPageData ) {
a . renderPage ( w , r , status , "auth.html" , web . PageData {
2026-05-30 15:42:35 +03:00
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 ) {
2026-05-31 13:02:58 +03:00
user , ok , _ := a . currentUserWithAuthError ( r )
return user , ok
}
func ( a * App ) currentUserWithAuthError ( r * http . Request ) ( services . User , bool , error ) {
2026-05-31 12:50:13 +03:00
// 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 {
2026-05-31 13:02:58 +03:00
user , err := a . authService . UserForAPIToken ( raw )
if err != nil {
return services . User { } , false , err
2026-05-31 12:50:13 +03:00
}
2026-05-31 13:02:58 +03:00
return user , true , nil
2026-05-31 12:50:13 +03:00
}
}
2026-05-30 15:42:35 +03:00
cookie , err := r . Cookie ( userSessionCookieName )
if err != nil {
2026-05-31 13:02:58 +03:00
return services . User { } , false , nil
2026-05-30 15:42:35 +03:00
}
user , _ , err := a . authService . UserForSession ( cookie . Value )
2026-05-31 13:02:58 +03:00
if err != nil {
return services . User { } , false , nil
}
return user , true , nil
2026-05-30 15:42:35 +03:00
}
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
}