mirror of
https://github.com/rjNemo/auth
synced 2026-06-12 11: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.
|
- 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.
|
- 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.
|
- 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
|
@ -8,13 +9,21 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
log.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
srv, err := server.New()
|
srv, err := server.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("initialise server: %v", err)
|
return fmt.Errorf("initialise server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Starting server on http://localhost:8000")
|
log.Println("Starting server on http://localhost:8000")
|
||||||
if err := http.ListenAndServe(":8000", srv.Router()); err != nil {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -10,8 +14,12 @@ var (
|
||||||
ErrInvalidInput = errors.New("auth: invalid input")
|
ErrInvalidInput = errors.New("auth: invalid input")
|
||||||
// ErrInvalidCredentials indicates the credentials do not match any account.
|
// ErrInvalidCredentials indicates the credentials do not match any account.
|
||||||
ErrInvalidCredentials = errors.New("auth: invalid credentials")
|
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.
|
// Service exposes authentication business operations to HTTP handlers.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store UserStore
|
store UserStore
|
||||||
|
|
@ -51,3 +59,49 @@ func (s *Service) LookupByEmail(ctx context.Context, email UserEmail) (*User, er
|
||||||
|
|
||||||
return s.store.FindByEmail(ctx, email)
|
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.
|
// Create inserts or replaces the stored user by email.
|
||||||
|
|
||||||
func (s *MemoryStore) Create(_ context.Context, user User) error {
|
func (s *MemoryStore) Create(_ context.Context, user User) error {
|
||||||
if user.Email.IsZero() {
|
if user.Email.IsZero() {
|
||||||
return ErrEmailRequired
|
return ErrEmailRequired
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,25 @@ import (
|
||||||
"github.com/rjnemo/auth/internal/auth"
|
"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 {
|
func (s *Server) loginHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
state := sessionFromContext(r.Context())
|
state := sessionFromContext(r.Context())
|
||||||
|
|
@ -23,7 +42,7 @@ func (s *Server) loginHandler() http.HandlerFunc {
|
||||||
email, err := auth.NewUserEmail(emailInput)
|
email, err := auth.NewUserEmail(emailInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +58,7 @@ func (s *Server) loginHandler() http.HandlerFunc {
|
||||||
|
|
||||||
case errors.Is(err, auth.ErrInvalidInput):
|
case errors.Is(err, auth.ErrInvalidInput):
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
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):
|
case errors.Is(err, auth.ErrInvalidCredentials):
|
||||||
s.renderLoginFailure(w, email, state.CSRFToken)
|
s.renderLoginFailure(w, email, state.CSRFToken)
|
||||||
default:
|
default:
|
||||||
|
|
@ -58,5 +77,46 @@ func (s *Server) logoutHandler() http.HandlerFunc {
|
||||||
|
|
||||||
func (s *Server) renderLoginFailure(w http.ResponseWriter, email auth.UserEmail, token string) {
|
func (s *Server) renderLoginFailure(w http.ResponseWriter, email auth.UserEmail, token string) {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
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 {
|
func (s *Server) indexHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
state := sessionFromContext(r.Context())
|
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))
|
s.render(w, "index.html", newIndexData(state.Email, "", state.CSRFToken))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,35 @@
|
||||||
package server
|
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) {
|
func (s *Server) registerRoutes(r chi.Router) {
|
||||||
r.Get("/", s.indexHandler())
|
r.Get("/", s.indexHandler())
|
||||||
r.Get("/in", s.dashboardHandler())
|
|
||||||
r.Post("/login", s.loginHandler())
|
r.Post("/login", s.loginHandler())
|
||||||
r.Post("/logout", s.logoutHandler())
|
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"
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
|
|
||||||
"github.com/rjnemo/auth/internal/auth"
|
"github.com/rjnemo/auth/internal/auth"
|
||||||
"github.com/rjnemo/auth/web"
|
"github.com/rjnemo/auth/web"
|
||||||
)
|
)
|
||||||
|
|
@ -33,6 +29,7 @@ func New() (*Server, error) {
|
||||||
web.Templates,
|
web.Templates,
|
||||||
"templates/index.html",
|
"templates/index.html",
|
||||||
"templates/in.html",
|
"templates/in.html",
|
||||||
|
"templates/signup.html",
|
||||||
"templates/unauthorized.html",
|
"templates/unauthorized.html",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -61,21 +58,6 @@ func New() (*Server, error) {
|
||||||
}, nil
|
}, 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 {
|
func seedUser(store auth.UserStore) error {
|
||||||
salt, hash, err := auth.HashPassword(seedPassword)
|
salt, hash, err := auth.HashPassword(seedPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ package server
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
Email string
|
Email string
|
||||||
Error string
|
Error string
|
||||||
|
Info string
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
CreatedAtISO string
|
CreatedAtISO string
|
||||||
|
|
@ -20,3 +21,7 @@ func newUnauthorizedData(errMsg, token string) PageData {
|
||||||
func newDashboardData(email, token, createdAt, createdAtISO string) PageData {
|
func newDashboardData(email, token, createdAt, createdAtISO string) PageData {
|
||||||
return PageData{Email: email, CSRFToken: token, CreatedAt: createdAt, CreatedAtISO: createdAtISO}
|
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>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
<p>
|
||||||
|
Need an account?
|
||||||
|
<a href="/signup">Create one now</a>.
|
||||||
|
</p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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