feat: add signup flow

This commit is contained in:
Ruidy 2025-09-20 12:49:32 +02:00
parent 3c7092236f
commit d0311ccd24
No known key found for this signature in database
GPG key ID: 705C24D202990805
11 changed files with 219 additions and 27 deletions

View file

@ -36,3 +36,4 @@
- Cover hashing and CSRF helpers with table-driven unit tests.
- Use `net/http/httptest` to verify happy-path login, signup, logout, invalid credential handling, CSRF failures, and session persistence.
- Run `go test -cover ./...` to ensure the new logic maintains regression coverage.
- Flesh out dedicated service tests for lookup flows and extend dashboard coverage once integration scaffolding is available.

View file

@ -1,6 +1,7 @@
package main
import (
"fmt"
"log"
"net/http"
@ -8,13 +9,21 @@ import (
)
func main() {
if err := run(); err != nil {
log.Fatalf("run: %v", err)
}
}
func run() error {
srv, err := server.New()
if err != nil {
log.Fatalf("initialise server: %v", err)
return fmt.Errorf("initialise server: %v", err)
}
log.Println("Starting server on http://localhost:8000")
if err := http.ListenAndServe(":8000", srv.Router()); err != nil {
log.Fatalf("listen: %v", err)
return fmt.Errorf("listen: %v", err)
}
return nil
}

View file

@ -2,7 +2,11 @@ package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
)
var (
@ -10,8 +14,12 @@ var (
ErrInvalidInput = errors.New("auth: invalid input")
// ErrInvalidCredentials indicates the credentials do not match any account.
ErrInvalidCredentials = errors.New("auth: invalid credentials")
// ErrEmailExists indicates an account already uses the provided email address.
ErrEmailExists = errors.New("auth: email already registered")
)
const userIDByteLength = 16
// Service exposes authentication business operations to HTTP handlers.
type Service struct {
store UserStore
@ -51,3 +59,49 @@ func (s *Service) LookupByEmail(ctx context.Context, email UserEmail) (*User, er
return s.store.FindByEmail(ctx, email)
}
// Register provisions a new user account for the provided credentials.
func (s *Service) Register(ctx context.Context, email UserEmail, password string) (*User, error) {
if email.IsZero() || password == "" {
return nil, ErrInvalidInput
}
if existing, err := s.store.FindByEmail(ctx, email); err == nil && existing != nil {
return nil, ErrEmailExists
} else if err != nil && !errors.Is(err, ErrUserNotFound) {
return nil, err
}
id, err := generateUserID()
if err != nil {
return nil, fmt.Errorf("generate user id: %w", err)
}
salt, hash, err := HashPassword(password)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
user := User{
ID: id,
Email: email,
PasswordSalt: salt,
PasswordHash: hash,
CreatedAt: time.Now().UTC(),
}
if err := s.store.Create(ctx, user); err != nil {
return nil, err
}
return &user, nil
}
// TODO: could be UUID. return a dedicated type
func generateUserID() (string, error) {
buf := make([]byte, userIDByteLength)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

View file

@ -48,7 +48,6 @@ func (s *MemoryStore) FindByEmail(_ context.Context, email UserEmail) (*User, er
}
// Create inserts or replaces the stored user by email.
func (s *MemoryStore) Create(_ context.Context, user User) error {
if user.Email.IsZero() {
return ErrEmailRequired

View file

@ -8,6 +8,25 @@ import (
"github.com/rjnemo/auth/internal/auth"
)
const (
credentialRequiredMsg = "Email and password are required."
invalidCredentialsMsg = "Invalid credentials."
duplicateEmailMsg = "An account with that email already exists."
)
func (s *Server) signupHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
state := sessionFromContext(r.Context())
if state.Authenticated {
http.Redirect(w, r, "/in", http.StatusSeeOther)
return
}
s.render(w, "signup.html", newSignupData(state.Email, "", state.CSRFToken))
}
}
func (s *Server) loginHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
state := sessionFromContext(r.Context())
@ -23,7 +42,7 @@ func (s *Server) loginHandler() http.HandlerFunc {
email, err := auth.NewUserEmail(emailInput)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
s.render(w, "index.html", newIndexData("", "Email and password are required.", state.CSRFToken))
s.render(w, "index.html", newIndexData("", credentialRequiredMsg, state.CSRFToken))
return
}
@ -39,7 +58,7 @@ func (s *Server) loginHandler() http.HandlerFunc {
case errors.Is(err, auth.ErrInvalidInput):
w.WriteHeader(http.StatusBadRequest)
s.render(w, "index.html", newIndexData(email.String(), "Email and password are required.", state.CSRFToken))
s.render(w, "index.html", newIndexData(email.String(), credentialRequiredMsg, state.CSRFToken))
case errors.Is(err, auth.ErrInvalidCredentials):
s.renderLoginFailure(w, email, state.CSRFToken)
default:
@ -58,5 +77,46 @@ func (s *Server) logoutHandler() http.HandlerFunc {
func (s *Server) renderLoginFailure(w http.ResponseWriter, email auth.UserEmail, token string) {
w.WriteHeader(http.StatusUnauthorized)
s.render(w, "index.html", newIndexData(email.String(), "Invalid credentials.", token))
s.render(w, "index.html", newIndexData(email.String(), invalidCredentialsMsg, token))
}
func (s *Server) registerHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
state := sessionFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form submission", http.StatusBadRequest)
return
}
emailValue := r.FormValue("email")
password := r.FormValue("password")
email, err := auth.NewUserEmail(emailValue)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
s.render(w, "signup.html", newSignupData("", credentialRequiredMsg, state.CSRFToken))
return
}
account, err := s.authService.Register(r.Context(), email, password)
switch {
case err == nil:
state.Authenticated = true
state.Email = account.Email.String()
if err := s.sessions.Save(w, state); err != nil {
log.Printf("session: save failed: %v", err)
}
http.Redirect(w, r, "/in", http.StatusSeeOther)
case errors.Is(err, auth.ErrInvalidInput):
w.WriteHeader(http.StatusBadRequest)
s.render(w, "signup.html", newSignupData(email.String(), credentialRequiredMsg, state.CSRFToken))
case errors.Is(err, auth.ErrEmailExists):
w.WriteHeader(http.StatusConflict)
s.render(w, "signup.html", newSignupData(email.String(), duplicateEmailMsg, state.CSRFToken))
default:
log.Printf("auth: register failed: %v", err)
http.Error(w, "unexpected error", http.StatusInternalServerError)
}
}
}

View file

@ -5,6 +5,10 @@ import "net/http"
func (s *Server) indexHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
state := sessionFromContext(r.Context())
if state.Authenticated {
http.Redirect(w, r, "/in", http.StatusSeeOther)
return
}
s.render(w, "index.html", newIndexData(state.Email, "", state.CSRFToken))
}
}

View file

@ -1,10 +1,35 @@
package server
import "github.com/go-chi/chi/v5"
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func (s *Server) registerRoutes(r chi.Router) {
r.Get("/", s.indexHandler())
r.Get("/in", s.dashboardHandler())
r.Post("/login", s.loginHandler())
r.Post("/logout", s.logoutHandler())
r.Get("/signup", s.signupHandler())
r.Post("/signup", s.registerHandler())
r.Get("/in", s.dashboardHandler())
}
// Router returns the configured HTTP router.
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(
middleware.RequestID,
middleware.RealIP,
middleware.Logger,
middleware.Recoverer,
s.sessionMiddleware,
s.csrfMiddleware,
)
s.registerRoutes(r)
return r
}

View file

@ -5,12 +5,8 @@ import (
"crypto/rand"
"fmt"
"html/template"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rjnemo/auth/internal/auth"
"github.com/rjnemo/auth/web"
)
@ -33,6 +29,7 @@ func New() (*Server, error) {
web.Templates,
"templates/index.html",
"templates/in.html",
"templates/signup.html",
"templates/unauthorized.html",
)
if err != nil {
@ -61,21 +58,6 @@ func New() (*Server, error) {
}, nil
}
// Router returns the configured HTTP router.
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(
middleware.RequestID,
middleware.RealIP,
middleware.Logger,
middleware.Recoverer,
s.sessionMiddleware,
s.csrfMiddleware,
)
s.registerRoutes(r)
return r
}
func seedUser(store auth.UserStore) error {
salt, hash, err := auth.HashPassword(seedPassword)
if err != nil {

View file

@ -4,6 +4,7 @@ package server
type PageData struct {
Email string
Error string
Info string
CSRFToken string
CreatedAt string
CreatedAtISO string
@ -20,3 +21,7 @@ func newUnauthorizedData(errMsg, token string) PageData {
func newDashboardData(email, token, createdAt, createdAtISO string) PageData {
return PageData{Email: email, CSRFToken: token, CreatedAt: createdAt, CreatedAtISO: createdAtISO}
}
func newSignupData(email, errMsg, token string) PageData {
return PageData{Email: email, Error: errMsg, CSRFToken: token}
}

View file

@ -49,6 +49,10 @@
/>
<button type="submit">Login</button>
</form>
<p>
Need an account?
<a href="/signup">Create one now</a>.
</p>
</main>
</body>
</html>

49
web/templates/signup.html Normal file
View file

@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Create Account</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
/>
</head>
<body>
<main class="container">
<h1>Sign up</h1>
<p>Provide your email and a strong password to create an account.</p>
{{if .Error}}
<article class="secondary">
<strong>Unable to sign up:</strong> {{.Error}}
</article>
{{end}}
<form method="post" action="/signup">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}" />
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
placeholder="Enter your email"
required
autofocus
value="{{.Email}}"
/>
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Choose a password"
required
/>
<button type="submit">Create account</button>
</form>
<p>
Already registered?
<a href="/">Return to sign in</a>.
</p>
</main>
</body>
</html>