mirror of
https://github.com/rjNemo/auth
synced 2026-06-06 00:16:40 +00:00
feat: add authentication core
This commit is contained in:
parent
6180b24cf6
commit
55a03ae248
16 changed files with 340 additions and 41 deletions
|
|
@ -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
50
internal/auth/password.go
Normal 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
68
internal/auth/store.go
Normal 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
12
internal/auth/user.go
Normal 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
|
||||
}
|
||||
8
internal/identity/email.go
Normal file
8
internal/identity/email.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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."))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
13
internal/server/render.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
49
internal/server/session.go
Normal file
49
internal/server/session.go
Normal 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
15
internal/server/views.go
Normal 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}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue