package server import ( "crypto/subtle" "net/http" "strings" "time" "github.com/cdnutter/canon/internal/auth" ) type loginPage struct { Error string Username string Next string } // safeNext only allows local redirect targets. func safeNext(next string) string { if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") { return "/admin" } return next } func (s *Server) needsSetup(r *http.Request) bool { n, err := s.store.UserCount(r.Context()) return err == nil && n == 0 } func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { if s.needsSetup(r) { http.Redirect(w, r, "/setup", http.StatusSeeOther) return } if _, ok := s.currentUser(r); ok { http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther) return } s.render(w, r, http.StatusOK, "login", "Sign in", loginPage{Next: r.URL.Query().Get("next")}) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if !s.sameOrigin(r) { http.Error(w, "Cross-site request blocked", http.StatusForbidden) return } r.Body = http.MaxBytesReader(w, r.Body, 64<<10) username := strings.TrimSpace(r.PostFormValue("username")) password := r.PostFormValue("password") next := r.PostFormValue("next") page := loginPage{Username: username, Next: next} ip := s.clientIP(r) if s.loginLimit.Blocked(ip) { page.Error = "Too many failed attempts. Try again in a few minutes." s.render(w, r, http.StatusTooManyRequests, "login", "Sign in", page) return } u, err := s.store.UserByName(r.Context(), username) if err != nil { auth.BurnTime(password) } if err != nil || !auth.CheckPassword(u.PasswordHash, password) { s.loginLimit.Fail(ip) page.Error = "That username and password don't match." s.render(w, r, http.StatusUnauthorized, "login", "Sign in", page) return } s.loginLimit.Reset(ip) if err := s.startSession(w, r, u.ID); err != nil { s.serverError(w, r, err) return } http.Redirect(w, r, safeNext(next), http.StatusSeeOther) } func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID int64) error { token, hash := auth.NewToken() expires := time.Now().Add(sessionTTL) if err := s.store.CreateSession(r.Context(), hash, userID, expires); err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: token, Path: "/", Expires: expires, HttpOnly: true, Secure: s.isHTTPS(r), SameSite: http.SameSiteLaxMode, }) return nil } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if !s.sameOrigin(r) { http.Error(w, "Cross-site request blocked", http.StatusForbidden) return } if c, err := r.Cookie(sessionCookie); err == nil { s.store.DeleteSession(r.Context(), auth.HashToken(c.Value)) } http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) http.Redirect(w, r, "/", http.StatusSeeOther) } type setupPage struct { Error string Username string } func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) { if !s.needsSetup(r) { http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.render(w, r, http.StatusOK, "setup", "Welcome", setupPage{}) } func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { if !s.sameOrigin(r) { http.Error(w, "Cross-site request blocked", http.StatusForbidden) return } if !s.needsSetup(r) { http.Redirect(w, r, "/login", http.StatusSeeOther) return } r.Body = http.MaxBytesReader(w, r.Body, 64<<10) ip := s.clientIP(r) page := setupPage{Username: strings.TrimSpace(r.PostFormValue("username"))} password := r.PostFormValue("password") code := normalizeCode(r.PostFormValue("code")) fail := func(status int, msg string) { page.Error = msg s.render(w, r, status, "setup", "Welcome", page) } if s.loginLimit.Blocked(ip) { fail(http.StatusTooManyRequests, "Too many failed attempts. Try again in a few minutes.") return } if s.setupCode == "" || subtle.ConstantTimeCompare([]byte(code), []byte(normalizeCode(s.setupCode))) != 1 { s.loginLimit.Fail(ip) fail(http.StatusForbidden, "That setup code isn't right. It's printed in the server log (docker compose logs canon).") return } if page.Username == "" || len(page.Username) > 64 { fail(http.StatusBadRequest, "Pick a username (up to 64 characters).") return } if len(password) < auth.MinPasswordLength { fail(http.StatusBadRequest, "Use a password of at least 8 characters.") return } if password != r.PostFormValue("confirm") { fail(http.StatusBadRequest, "The passwords don't match.") return } hash, err := auth.HashPassword(password) if err != nil { s.serverError(w, r, err) return } if err := s.store.CreateUser(r.Context(), page.Username, hash); err != nil { s.serverError(w, r, err) return } s.log.Info("admin account created", "username", page.Username) u, err := s.store.UserByName(r.Context(), page.Username) if err != nil { s.serverError(w, r, err) return } if err := s.startSession(w, r, u.ID); err != nil { s.serverError(w, r, err) return } http.Redirect(w, r, "/admin", http.StatusSeeOther) } // normalizeCode makes setup codes forgiving of case, spaces and dashes. func normalizeCode(c string) string { return strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(c))) }