mirror of
https://github.com/rjNemo/auth
synced 2026-06-06 08:26:39 +00:00
feat: add signup flow
This commit is contained in:
parent
3c7092236f
commit
d0311ccd24
11 changed files with 219 additions and 27 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
49
web/templates/signup.html
Normal 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>
|
||||
Loading…
Reference in a new issue