package web import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "html/template" "log" "net/http" "strings" "time" "infinite-noodle/internal/assets" "infinite-noodle/internal/noodle" ) const sessionCookieName = "infinite_noodle_session" var errInvalidCredentials = errors.New("invalid credentials") type Auth struct { db *noodle.Database secret []byte } type loginPageData struct { Error string } type authUser struct { Username string Role string } func NewAuth(db *noodle.Database) (*Auth, error) { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { return nil, err } return &Auth{ db: db, secret: secret, }, nil } func HashPassword(password string) string { sum := sha256.Sum256([]byte(password)) return hex.EncodeToString(sum[:]) } func (a *Auth) EnsureDefaultUser() error { users := a.db.GetAllUsers() if len(users) == 0 { user := noodle.User{ Id: a.db.MakeID(), Username: "admin", Role: noodle.UserRoleAdmin, PasswordHash: HashPassword("admin"), CreatedAt: time.Now().UTC(), } if err := a.db.AddUser(user); err != nil { return err } log.Printf("Created default user username=%q password=%q", user.Username, "admin") return nil } var hasAdmin bool for _, user := range users { updated := false if user.Role == "" { user.Role = noodle.UserRoleRegular updated = true } if user.Username == "admin" { user.Role = noodle.UserRoleAdmin updated = true } if updated { if err := a.db.UpdateUser(user); err != nil { return err } } if user.Role == noodle.UserRoleAdmin { hasAdmin = true } } if hasAdmin { return nil } user := noodle.User{ Id: a.db.MakeID(), Username: "admin", Role: noodle.UserRoleAdmin, PasswordHash: HashPassword("admin"), CreatedAt: time.Now().UTC(), } if err := a.db.AddUser(user); err != nil { return err } log.Printf("Created fallback admin user username=%q password=%q", user.Username, "admin") return nil } func (a *Auth) RequireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { if _, ok := a.CurrentUser(req); !ok { http.Redirect(w, req, "/login", http.StatusSeeOther) return } next(w, req) } } func (a *Auth) RequireAdmin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { user, ok := a.CurrentUser(req) if !ok { http.Redirect(w, req, "/login", http.StatusSeeOther) return } if user.Role != noodle.UserRoleAdmin { http.Error(w, "admin access required", http.StatusForbidden) return } next(w, req) } } func (a *Auth) CurrentUser(req *http.Request) (authUser, bool) { cookie, err := req.Cookie(sessionCookieName) if err != nil { return authUser{}, false } username, ok := a.verifyCookieValue(cookie.Value) if !ok { return authUser{}, false } user, err := a.db.GetUserByUsername(username) if err != nil || user.Username == "" { return authUser{}, false } role := user.Role if role == "" { role = noodle.UserRoleRegular } return authUser{ Username: user.Username, Role: role, }, true } func (a *Auth) HandleLogin() http.HandlerFunc { tmpl, err := template.ParseFS(assets.FS, "templates/*.html") if err != nil { log.Fatalf("Error parsing templates: %v", err) } return func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodGet { if _, ok := a.CurrentUser(req); ok { http.Redirect(w, req, "/", http.StatusSeeOther) return } if err := tmpl.ExecuteTemplate(w, "login.html", loginPageData{}); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } return } if req.Method != http.MethodPost { http.Error(w, "", http.StatusMethodNotAllowed) return } if err := req.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } username := strings.TrimSpace(req.FormValue("username")) password := req.FormValue("password") if username == "" || password == "" { w.WriteHeader(http.StatusUnauthorized) if err := tmpl.ExecuteTemplate(w, "login.html", loginPageData{Error: "Username and password are required."}); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } return } if err := a.Login(w, username, password); err != nil { w.WriteHeader(http.StatusUnauthorized) if err := tmpl.ExecuteTemplate(w, "login.html", loginPageData{Error: "Invalid username or password."}); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } return } http.Redirect(w, req, "/", http.StatusSeeOther) } } func (a *Auth) HandleLogout() http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(w, "", http.StatusMethodNotAllowed) return } a.ClearSession(w) http.Redirect(w, req, "/login", http.StatusSeeOther) } } func (a *Auth) Login(w http.ResponseWriter, username, password string) error { user, err := a.db.GetUserByUsername(username) if err != nil { return errInvalidCredentials } if user.PasswordHash != HashPassword(password) { return errInvalidCredentials } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: a.cookieValue(username), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds()), }) return nil } func (a *Auth) ClearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) } func (a *Auth) cookieValue(username string) string { mac := hmac.New(sha256.New, a.secret) mac.Write([]byte(username)) sig := mac.Sum(nil) payload := fmt.Sprintf("%s:%s", username, hex.EncodeToString(sig)) return base64.RawURLEncoding.EncodeToString([]byte(payload)) } func (a *Auth) verifyCookieValue(value string) (string, bool) { decoded, err := base64.RawURLEncoding.DecodeString(value) if err != nil { return "", false } parts := strings.SplitN(string(decoded), ":", 2) if len(parts) != 2 || parts[0] == "" { return "", false } expected := a.cookieValue(parts[0]) return parts[0], hmac.Equal([]byte(expected), []byte(value)) }