2025-07-15 16:40:15 +01:00
package auth
import (
2025-11-06 21:24:52 +00:00
"arimelody-web/admin/core"
2025-07-15 16:40:15 +01:00
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"database/sql"
"fmt"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func RegisterAccountHandler ( app * model . AppState ) http . Handler {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
session := r . Context ( ) . Value ( "session" ) . ( * model . Session )
if session . Account != nil {
// user is already logged in
http . Redirect ( w , r , "/admin" , http . StatusFound )
return
}
type registerData struct {
Session * model . Session
}
render := func ( ) {
err := templates . RegisterTemplate . Execute ( w , registerData { Session : session } )
if err != nil {
fmt . Printf ( "WARN: Error rendering create account page: %s\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
}
}
if r . Method == http . MethodGet {
render ( )
return
}
if r . Method != http . MethodPost {
http . NotFound ( w , r )
return
}
err := r . ParseForm ( )
if err != nil {
http . Error ( w , http . StatusText ( http . StatusBadRequest ) , http . StatusBadRequest )
return
}
type RegisterRequest struct {
Username string ` json:"username" `
Email string ` json:"email" `
Password string ` json:"password" `
Invite string ` json:"invite" `
}
credentials := RegisterRequest {
Username : r . Form . Get ( "username" ) ,
Email : r . Form . Get ( "email" ) ,
Password : r . Form . Get ( "password" ) ,
Invite : r . Form . Get ( "invite" ) ,
}
// make sure invite code exists in DB
invite , err := controller . GetInvite ( app . DB , credentials . Invite )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to retrieve invite: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
if invite == nil || time . Now ( ) . After ( invite . ExpiresAt ) {
if invite != nil {
err := controller . DeleteInvite ( app . DB , invite . Code )
if err != nil { fmt . Fprintf ( os . Stderr , "WARN: Failed to delete expired invite: %v\n" , err ) }
}
controller . SetSessionError ( app . DB , session , "Invalid invite code." )
render ( )
return
}
hashedPassword , err := bcrypt . GenerateFromPassword ( [ ] byte ( credentials . Password ) , bcrypt . DefaultCost )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to generate password hash: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
account := model . Account {
Username : credentials . Username ,
Password : string ( hashedPassword ) ,
Email : sql . NullString { String : credentials . Email , Valid : true } ,
AvatarURL : sql . NullString { String : "/img/default-avatar.png" , Valid : true } ,
}
err = controller . CreateAccount ( app . DB , & account )
if err != nil {
if strings . HasPrefix ( err . Error ( ) , "pq: duplicate key" ) {
controller . SetSessionError ( app . DB , session , "An account with that username already exists." )
render ( )
return
}
fmt . Fprintf ( os . Stderr , "WARN: Failed to create account: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
app . Log . Info ( log . TYPE_ACCOUNT , "Account \"%s\" (%s) created using invite \"%s\". (%s)" , account . Username , account . ID , invite . Code , controller . ResolveIP ( app , r ) )
err = controller . DeleteInvite ( app . DB , invite . Code )
if err != nil {
app . Log . Warn ( log . TYPE_ACCOUNT , "Failed to delete expired invite \"%s\": %v" , invite . Code , err )
}
// registration success!
controller . SetSessionAccount ( app . DB , session , & account )
controller . SetSessionMessage ( app . DB , session , "" )
controller . SetSessionError ( app . DB , session , "" )
http . Redirect ( w , r , "/admin" , http . StatusFound )
} )
}
func LoginHandler ( app * model . AppState ) http . Handler {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
if r . Method != http . MethodGet && r . Method != http . MethodPost {
http . NotFound ( w , r )
return
}
session := r . Context ( ) . Value ( "session" ) . ( * model . Session )
render := func ( ) {
2025-11-06 21:24:52 +00:00
err := templates . LoginTemplate . Execute ( w , core . AdminPageData { Session : session } )
2025-07-15 16:40:15 +01:00
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Error rendering admin login page: %s\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
return
}
}
if r . Method == http . MethodGet {
if session . Account != nil {
// user is already logged in
http . Redirect ( w , r , "/admin" , http . StatusFound )
return
}
render ( )
return
}
err := r . ParseForm ( )
if err != nil {
http . Error ( w , http . StatusText ( http . StatusBadRequest ) , http . StatusBadRequest )
return
}
if ! r . Form . Has ( "username" ) || ! r . Form . Has ( "password" ) {
http . Error ( w , http . StatusText ( http . StatusBadRequest ) , http . StatusBadRequest )
return
}
username := r . FormValue ( "username" )
password := r . FormValue ( "password" )
account , err := controller . GetAccountByUsername ( app . DB , username )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to fetch account for login: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Invalid username or password." )
render ( )
return
}
if account == nil {
controller . SetSessionError ( app . DB , session , "Invalid username or password." )
render ( )
return
}
if account . Locked {
controller . SetSessionError ( app . DB , session , "This account is locked." )
render ( )
return
}
err = bcrypt . CompareHashAndPassword ( [ ] byte ( account . Password ) , [ ] byte ( password ) )
if err != nil {
app . Log . Warn ( log . TYPE_ACCOUNT , "\"%s\" attempted login with incorrect password. (%s)" , account . Username , controller . ResolveIP ( app , r ) )
if locked := handleFailedLogin ( app , account , r ) ; locked {
controller . SetSessionError ( app . DB , session , "Too many failed attempts. This account is now locked." )
} else {
controller . SetSessionError ( app . DB , session , "Invalid username or password." )
}
render ( )
return
}
totps , err := controller . GetTOTPsForAccount ( app . DB , account . ID )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to fetch TOTPs: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
if len ( totps ) > 0 {
err = controller . SetSessionAttemptAccount ( app . DB , session , account )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to set attempt session: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
controller . SetSessionMessage ( app . DB , session , "" )
controller . SetSessionError ( app . DB , session , "" )
http . Redirect ( w , r , "/admin/totp" , http . StatusFound )
return
}
// login success!
// TODO: log login activity to user
app . Log . Info ( log . TYPE_ACCOUNT , "\"%s\" logged in. (%s)" , account . Username , controller . ResolveIP ( app , r ) )
app . Log . Warn ( log . TYPE_ACCOUNT , "\"%s\" does not have any TOTP methods assigned." , account . Username )
err = controller . SetSessionAccount ( app . DB , session , account )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to set session account: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
controller . SetSessionMessage ( app . DB , session , "" )
controller . SetSessionError ( app . DB , session , "" )
http . Redirect ( w , r , "/admin" , http . StatusFound )
} )
}
func LoginTOTPHandler ( app * model . AppState ) http . Handler {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
session := r . Context ( ) . Value ( "session" ) . ( * model . Session )
if session . AttemptAccount == nil {
http . Error ( w , http . StatusText ( http . StatusUnauthorized ) , http . StatusUnauthorized )
return
}
render := func ( ) {
2025-11-06 21:24:52 +00:00
err := templates . LoginTOTPTemplate . Execute ( w , core . AdminPageData { Session : session } )
2025-07-15 16:40:15 +01:00
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to render login TOTP page: %v\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
return
}
}
if r . Method == http . MethodGet {
render ( )
return
}
if r . Method != http . MethodPost {
http . NotFound ( w , r )
return
}
r . ParseForm ( )
if ! r . Form . Has ( "totp" ) {
http . Error ( w , http . StatusText ( http . StatusBadRequest ) , http . StatusBadRequest )
return
}
totpCode := r . FormValue ( "totp" )
if len ( totpCode ) != controller . TOTP_CODE_LENGTH {
app . Log . Warn ( log . TYPE_ACCOUNT , "\"%s\" failed login (Invalid TOTP). (%s)" , session . AttemptAccount . Username , controller . ResolveIP ( app , r ) )
controller . SetSessionError ( app . DB , session , "Invalid TOTP." )
render ( )
return
}
totpMethod , err := controller . CheckTOTPForAccount ( app . DB , session . AttemptAccount . ID , totpCode )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to check TOTPs: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
if totpMethod == nil {
app . Log . Warn ( log . TYPE_ACCOUNT , "\"%s\" failed login (Incorrect TOTP). (%s)" , session . AttemptAccount . Username , controller . ResolveIP ( app , r ) )
if locked := handleFailedLogin ( app , session . AttemptAccount , r ) ; locked {
controller . SetSessionError ( app . DB , session , "Too many failed attempts. This account is now locked." )
controller . SetSessionAttemptAccount ( app . DB , session , nil )
http . Redirect ( w , r , "/admin" , http . StatusFound )
} else {
controller . SetSessionError ( app . DB , session , "Incorrect TOTP." )
}
render ( )
return
}
app . Log . Info ( log . TYPE_ACCOUNT , "\"%s\" logged in with TOTP method \"%s\". (%s)" , session . AttemptAccount . Username , totpMethod . Name , controller . ResolveIP ( app , r ) )
err = controller . SetSessionAccount ( app . DB , session , session . AttemptAccount )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to set session account: %v\n" , err )
controller . SetSessionError ( app . DB , session , "Something went wrong. Please try again." )
render ( )
return
}
err = controller . SetSessionAttemptAccount ( app . DB , session , nil )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to clear attempt session: %v\n" , err )
}
controller . SetSessionMessage ( app . DB , session , "" )
controller . SetSessionError ( app . DB , session , "" )
http . Redirect ( w , r , "/admin" , http . StatusFound )
} )
}
func LogoutHandler ( 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
}
session := r . Context ( ) . Value ( "session" ) . ( * model . Session )
err := controller . DeleteSession ( app . DB , session . Token )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to delete session: %v\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
return
}
http . SetCookie ( w , & http . Cookie {
Name : model . COOKIE_TOKEN ,
Expires : time . Now ( ) ,
Path : "/" ,
} )
err = templates . LogoutTemplate . Execute ( w , nil )
if err != nil {
fmt . Fprintf ( os . Stderr , "WARN: Failed to render logout page: %v\n" , err )
http . Error ( w , http . StatusText ( http . StatusInternalServerError ) , http . StatusInternalServerError )
}
} )
}
func handleFailedLogin ( app * model . AppState , account * model . Account , r * http . Request ) bool {
locked , err := controller . IncrementAccountFails ( app . DB , account . ID )
if err != nil {
fmt . Fprintf (
os . Stderr ,
"WARN: Failed to increment login failures for \"%s\": %v\n" ,
account . Username ,
err ,
)
app . Log . Warn (
log . TYPE_ACCOUNT ,
"Failed to increment login failures for \"%s\"" ,
account . Username ,
)
}
if locked {
app . Log . Warn (
log . TYPE_ACCOUNT ,
"Account \"%s\" was locked: %d failed login attempts (IP: %s)" ,
account . Username ,
model . MAX_LOGIN_FAIL_ATTEMPTS ,
controller . ResolveIP ( app , r ) ,
)
}
return locked
}