feat: add authentication core

This commit is contained in:
Ruidy 2025-09-20 00:16:24 +02:00
parent 6180b24cf6
commit 55a03ae248
No known key found for this signature in database
GPG key ID: 705C24D202990805
16 changed files with 340 additions and 41 deletions

View file

@ -8,7 +8,10 @@ import (
)
func main() {
srv := server.New()
srv, err := server.New()
if err != nil {
log.Fatalf("initialise server: %v", err)
}
log.Println("Starting server on http://localhost:8000")
if err := http.ListenAndServe(":8000", srv.Router()); err != nil {

50
internal/auth/password.go Normal file
View file

@ -0,0 +1,50 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"log"
)
const saltLen = 32
// HashPassword returns a base64-encoded salt and hash for the provided plaintext.
func HashPassword(plain string) (salt string, hash string, err error) {
if plain == "" {
return "", "", fmt.Errorf("password cannot be empty")
}
rawSalt := make([]byte, saltLen)
if _, err = rand.Read(rawSalt); err != nil {
return "", "", fmt.Errorf("generate salt: %w", err)
}
salt = base64.StdEncoding.EncodeToString(rawSalt)
hash = encodeHash(rawSalt, plain)
return salt, hash, nil
}
// VerifyPassword reports whether the supplied plaintext matches the salt+hash pair.
func VerifyPassword(plain, salt, expectedHash string) bool {
if plain == "" || salt == "" || expectedHash == "" {
return false
}
rawSalt, err := base64.StdEncoding.DecodeString(salt)
if err != nil {
log.Printf("auth: invalid salt encoding: %v", err)
return false
}
calculated := encodeHash(rawSalt, plain)
return subtle.ConstantTimeCompare([]byte(calculated), []byte(expectedHash)) == 1
}
func encodeHash(salt []byte, plain string) string {
digest := sha256.Sum256(append(salt, plain...))
return base64.StdEncoding.EncodeToString(digest[:])
}

68
internal/auth/store.go Normal file
View file

@ -0,0 +1,68 @@
package auth
import (
"context"
"errors"
"sync"
"github.com/rjnemo/auth/internal/identity"
)
// ErrUserNotFound signals no user exists for the provided lookup criteria.
var ErrUserNotFound = errors.New("auth: user not found")
// UserStore defines persistence expectations for user lookups.
type UserStore interface {
FindByEmail(ctx context.Context, email string) (*User, error)
Create(ctx context.Context, user User) error
}
// MemoryStore is an in-memory implementation of UserStore for development and tests.
type MemoryStore struct {
mu sync.RWMutex
users map[string]User
}
// NewMemoryStore builds an empty MemoryStore instance.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{users: make(map[string]User)}
}
// FindByEmail returns a copy of the stored user.
func (s *MemoryStore) FindByEmail(_ context.Context, email string) (*User, error) {
key := identity.NormalizeEmail(email)
if key == "" {
return nil, ErrUserNotFound
}
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[key]
if !ok {
return nil, ErrUserNotFound
}
userCopy := user
return &userCopy, nil
}
// Create inserts or replaces the stored user by email.
func (s *MemoryStore) Create(_ context.Context, user User) error {
key := identity.NormalizeEmail(user.Email)
if key == "" {
return errors.New("auth: email required")
}
user.Email = key
s.mu.Lock()
defer s.mu.Unlock()
if s.users == nil {
s.users = make(map[string]User)
}
s.users[key] = user
return nil
}

12
internal/auth/user.go Normal file
View file

@ -0,0 +1,12 @@
package auth
import "time"
// User represents authenticated account details.
type User struct {
ID string
Email string
PasswordSalt string
PasswordHash string
CreatedAt time.Time
}

View file

@ -0,0 +1,8 @@
package identity
import "strings"
// NormalizeEmail trims whitespace and lowercases an email for canonical comparisons.
func NormalizeEmail(email string) string {
return strings.TrimSpace(strings.ToLower(email))
}

View file

@ -1,14 +1,52 @@
package server
import (
"log"
"net/http"
"github.com/rjnemo/auth/internal/auth"
"github.com/rjnemo/auth/internal/identity"
)
func (s *Server) loginHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Println("Login request received")
s.loggedIn = true
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form submission", http.StatusBadRequest)
return
}
email := identity.NormalizeEmail(r.FormValue("email"))
password := r.FormValue("password")
if email == "" || password == "" {
w.WriteHeader(http.StatusBadRequest)
s.render(w, "index.html", newIndexData(email, "Email and password are required."))
return
}
account, err := s.users.FindByEmail(r.Context(), email)
if err != nil {
s.renderLoginFailure(w, email)
return
}
if !auth.VerifyPassword(password, account.PasswordSalt, account.PasswordHash) {
s.renderLoginFailure(w, email)
return
}
s.sessions.SetAuthenticated(account.Email)
http.Redirect(w, r, "/in", http.StatusSeeOther)
}
}
func (s *Server) logoutHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.sessions.Clear()
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
func (s *Server) renderLoginFailure(w http.ResponseWriter, email string) {
w.WriteHeader(http.StatusUnauthorized)
s.render(w, "index.html", newIndexData(email, "Invalid credentials."))
}

View file

@ -1,24 +1,15 @@
package server
import (
"log"
"net/http"
)
import "net/http"
func (s *Server) dashboardHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.loggedIn {
if !s.sessions.IsAuthenticated() {
w.WriteHeader(http.StatusUnauthorized)
if err := s.templates.ExecuteTemplate(w, "unauthorized.html", nil); err != nil {
log.Printf("render unauthorized: %v", err)
http.Error(w, "template render failed", http.StatusInternalServerError)
}
s.render(w, "unauthorized.html", newUnauthorizedData("Sign in to continue."))
return
}
if err := s.templates.ExecuteTemplate(w, "in.html", nil); err != nil {
log.Printf("render dashboard: %v", err)
http.Error(w, "template render failed", http.StatusInternalServerError)
}
s.render(w, "in.html", PageData{Email: s.sessions.CurrentAccount()})
}
}

View file

@ -1,15 +1,9 @@
package server
import (
"log"
"net/http"
)
import "net/http"
func (s *Server) indexHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := s.templates.ExecuteTemplate(w, "index.html", nil); err != nil {
log.Printf("render index: %v", err)
http.Error(w, "template render failed", http.StatusInternalServerError)
}
s.render(w, "index.html", newIndexData("", ""))
}
}

13
internal/server/render.go Normal file
View file

@ -0,0 +1,13 @@
package server
import (
"log"
"net/http"
)
func (s *Server) render(w http.ResponseWriter, name string, data any) {
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("render %s: %v", name, err)
http.Error(w, "template render failed", http.StatusInternalServerError)
}
}

View file

@ -6,4 +6,5 @@ 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())
}

View file

@ -1,32 +1,52 @@
package server
import (
"context"
"fmt"
"html/template"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/rjnemo/auth/internal/auth"
"github.com/rjnemo/auth/web"
)
const (
seedEmail = "user@example.com"
seedPassword = "password123"
)
// Server holds HTTP dependencies for the application.
type Server struct {
templates *template.Template
loggedIn bool
users auth.UserStore
sessions *SessionManager
}
// New constructs a Server with parsed templates and default state.
func New() *Server {
tmpl := template.Must(template.ParseFS(
func New() (*Server, error) {
tmpl, err := template.ParseFS(
web.Templates,
"templates/index.html",
"templates/in.html",
"templates/unauthorized.html",
))
)
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
users := auth.NewMemoryStore()
if err := seedUser(users); err != nil {
return nil, fmt.Errorf("seed user: %w", err)
}
return &Server{
templates: tmpl,
}
users: users,
sessions: NewSessionManager(),
}, nil
}
// Router returns the configured HTTP router.
@ -35,3 +55,19 @@ func (s *Server) Router() http.Handler {
s.registerRoutes(r)
return r
}
func seedUser(store auth.UserStore) error {
salt, hash, err := auth.HashPassword(seedPassword)
if err != nil {
return err
}
ctx := context.Background()
return store.Create(ctx, auth.User{
ID: "seed-user",
Email: seedEmail,
PasswordSalt: salt,
PasswordHash: hash,
CreatedAt: time.Now().UTC(),
})
}

View file

@ -0,0 +1,49 @@
package server
import "sync"
// SessionManager is a placeholder for future session persistence.
type SessionManager struct {
mu sync.RWMutex
authenticated bool
currentAccount string
}
// NewSessionManager constructs an empty session manager.
func NewSessionManager() *SessionManager {
return &SessionManager{}
}
// SetAuthenticated marks the provided account as the active authenticated user.
func (m *SessionManager) SetAuthenticated(email string) {
m.mu.Lock()
defer m.mu.Unlock()
m.authenticated = true
m.currentAccount = email
}
// Clear removes any active authentication data.
func (m *SessionManager) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.authenticated = false
m.currentAccount = ""
}
// IsAuthenticated reports whether a user is currently considered logged in.
func (m *SessionManager) IsAuthenticated() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.authenticated
}
// CurrentAccount returns the email associated with the active session.
func (m *SessionManager) CurrentAccount() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.currentAccount
}

15
internal/server/views.go Normal file
View file

@ -0,0 +1,15 @@
package server
// PageData contains fields shared by the templates for now.
type PageData struct {
Email string
Error string
}
func newIndexData(email, errMsg string) PageData {
return PageData{Email: email, Error: errMsg}
}
func newUnauthorizedData(errMsg string) PageData {
return PageData{Error: errMsg}
}

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>auth test</title>
<title>Dashboard</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
@ -11,8 +11,12 @@
</head>
<body>
<main class="container">
<h1>Logged in</h1>
<p>You are logged in. This is a test page for the authentication</p>
<h1>Welcome</h1>
<p>You are signed in as <strong>{{.Email}}</strong>.</p>
<p>This placeholder dashboard will evolve as we flesh out the auth flow.</p>
<form method="post" action="/logout">
<button type="submit" class="secondary">Sign out</button>
</form>
</main>
</body>
</html>

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>auth test</title>
<title>Auth Demo</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
@ -11,20 +11,36 @@
</head>
<body>
<main class="container">
<h1>Login</h1>
<p>
This is a test page for the authentication system. Please log in to
continue.
</p>
<h1>Sign in</h1>
<p>Authenticate with the demo credentials below to view the dashboard.</p>
<article>
<header>Demo account</header>
<p><strong>Email:</strong> user@example.com<br /><strong>Password:</strong> password123</p>
</article>
{{if .Error}}
<article class="secondary">
<strong>Unable to sign in:</strong> {{.Error}}
</article>
{{end}}
<form method="post" action="/login">
<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="Password"
required
/>
<input type="password" id="password" placeholder="password" required />
<button type="submit">Login</button>
</form>
</main>

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>auth test</title>
<title>Unauthorized</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
@ -12,6 +12,7 @@
<body>
<main class="container">
<h1>Unauthorized</h1>
<p>{{if .Error}}{{.Error}}{{else}}You do not have permission to view that page.{{end}}</p>
<a href="/" role="button">Back to safety</a>
</main>
</body>