Junie: security cleanups.
This commit is contained in:
240
api/auth.go
240
api/auth.go
@@ -1,14 +1,18 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/data"
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
@@ -63,48 +67,51 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check password only first
|
||||
// Check password only
|
||||
if !user.CheckPassword(&req.Login) {
|
||||
s.sendError(w, "Invalid username or password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// If TOTP is enabled and a code was provided, verify it
|
||||
if user.HasTOTP() {
|
||||
if req.Login.TOTPCode == "" {
|
||||
// TOTP is enabled but no code was provided
|
||||
// Return a special response indicating TOTP is required
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
if err := json.NewEncoder(w).Encode(ErrorResponse{
|
||||
Error: "TOTP code required",
|
||||
}); err != nil {
|
||||
s.Logger.Printf("Error encoding response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the TOTP code
|
||||
valid, validErr := user.ValidateTOTPCode(req.Login.TOTPCode)
|
||||
if validErr != nil || !valid {
|
||||
s.sendError(w, "Invalid TOTP code", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Password is correct, create a token regardless of TOTP status
|
||||
token, expires, err := s.createToken(user.ID)
|
||||
if err != nil {
|
||||
s.Logger.Printf("Token creation error: %v", err)
|
||||
// Log the security event
|
||||
details := map[string]string{
|
||||
"reason": "Token creation error",
|
||||
"error": err.Error(),
|
||||
}
|
||||
s.LogSecurityEvent(r, "login_attempt", user.ID, user.User, false, details)
|
||||
s.sendError(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// If user has TOTP enabled, include this information in the response
|
||||
totpEnabled := user.HasTOTP()
|
||||
|
||||
// Log successful login
|
||||
details := map[string]string{
|
||||
"token_expires": time.Unix(expires, 0).UTC().Format(time.RFC3339),
|
||||
"totp_enabled": fmt.Sprintf("%t", totpEnabled),
|
||||
}
|
||||
s.LogSecurityEvent(r, "login_success", user.ID, user.User, true, details)
|
||||
|
||||
// Include TOTP status in the response
|
||||
response := struct {
|
||||
TokenResponse
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
}{
|
||||
TokenResponse: TokenResponse{
|
||||
Token: token,
|
||||
Expires: expires,
|
||||
},
|
||||
TOTPEnabled: totpEnabled,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(TokenResponse{
|
||||
Token: token,
|
||||
Expires: expires,
|
||||
}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
s.Logger.Printf("Error encoding response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -147,20 +154,61 @@ func (s *Server) handleTokenLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) sendError(w http.ResponseWriter, message string, status int) {
|
||||
// Log the detailed error message for debugging
|
||||
s.Logger.Printf("Error response: %s (Status: %d)", message, status)
|
||||
|
||||
// Create a generic error message based on status code
|
||||
publicMessage := "An error occurred processing your request"
|
||||
errorCode := "E" + string(status)
|
||||
|
||||
// Customize public messages for common status codes
|
||||
// but don't leak specific details about the error
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
publicMessage = "Invalid request format"
|
||||
case http.StatusUnauthorized:
|
||||
publicMessage = "Authentication required"
|
||||
case http.StatusForbidden:
|
||||
publicMessage = "Insufficient permissions"
|
||||
case http.StatusNotFound:
|
||||
publicMessage = "Resource not found"
|
||||
case http.StatusTooManyRequests:
|
||||
publicMessage = "Rate limit exceeded"
|
||||
case http.StatusInternalServerError:
|
||||
publicMessage = "Internal server error"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(ErrorResponse{Error: message}); err != nil {
|
||||
|
||||
response := struct {
|
||||
Error string `json:"error"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
}{
|
||||
Error: publicMessage,
|
||||
ErrorCode: errorCode,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
s.Logger.Printf("Error encoding error response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getUserByUsername(username string) (*data.User, error) {
|
||||
query := `SELECT id, created, user, password, salt, totp_secret FROM users WHERE user = ?`
|
||||
row := s.DB.QueryRow(query, username)
|
||||
// Use squirrel to build the query safely
|
||||
query, args, err := squirrel.Select("id", "created", "user", "password", "salt", "totp_secret").
|
||||
From("users").
|
||||
Where(squirrel.Eq{"user": username}).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := s.DB.QueryRow(query, args...)
|
||||
|
||||
user := &data.User{}
|
||||
var totpSecret sql.NullString
|
||||
err := row.Scan(&user.ID, &user.Created, &user.User, &user.Password, &user.Salt, &totpSecret)
|
||||
err = row.Scan(&user.ID, &user.Created, &user.User, &user.Password, &user.Salt, &totpSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -170,12 +218,17 @@ func (s *Server) getUserByUsername(username string) (*data.User, error) {
|
||||
user.TOTPSecret = totpSecret.String
|
||||
}
|
||||
|
||||
rolesQuery := `
|
||||
SELECT r.role FROM roles r
|
||||
JOIN user_roles ur ON r.id = ur.rid
|
||||
WHERE ur.uid = ?
|
||||
`
|
||||
rows, err := s.DB.Query(rolesQuery, user.ID)
|
||||
// Use squirrel to build the roles query safely
|
||||
rolesQuery, rolesArgs, err := squirrel.Select("r.role").
|
||||
From("roles r").
|
||||
Join("user_roles ur ON r.id = ur.rid").
|
||||
Where(squirrel.Eq{"ur.uid": user.ID}).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := s.DB.Query(rolesQuery, rolesArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -195,12 +248,28 @@ func (s *Server) getUserByUsername(username string) (*data.User, error) {
|
||||
}
|
||||
|
||||
func (s *Server) createToken(userID string) (string, int64, error) {
|
||||
token := ulid.Make().String()
|
||||
// Generate 16 bytes of random data
|
||||
tokenBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// Hex encode the random bytes to get a 32-character string
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
|
||||
expires := time.Now().Add(24 * time.Hour).Unix()
|
||||
query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)`
|
||||
tokenID := ulid.Make().String()
|
||||
_, err := s.DB.Exec(query, tokenID, userID, token, expires)
|
||||
|
||||
// Use squirrel to build the insert query safely
|
||||
query, args, err := squirrel.Insert("tokens").
|
||||
Columns("id", "uid", "token", "expires").
|
||||
Values(tokenID, userID, token, expires).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
_, err = s.DB.Exec(query, args...)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
@@ -209,14 +278,22 @@ func (s *Server) createToken(userID string) (string, int64, error) {
|
||||
}
|
||||
|
||||
func (s *Server) verifyToken(username, token string) (string, error) {
|
||||
query := `
|
||||
SELECT t.uid, t.expires FROM tokens t
|
||||
JOIN users u ON t.uid = u.id
|
||||
WHERE u.user = ? AND t.token = ?
|
||||
`
|
||||
// Use squirrel to build the query safely
|
||||
query, args, err := squirrel.Select("t.uid", "t.expires").
|
||||
From("tokens t").
|
||||
Join("users u ON t.uid = u.id").
|
||||
Where(squirrel.And{
|
||||
squirrel.Eq{"u.user": username},
|
||||
squirrel.Eq{"t.token": token},
|
||||
}).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var userID string
|
||||
var expires int64
|
||||
err := s.DB.QueryRow(query, username, token).Scan(&userID, &expires)
|
||||
err = s.DB.QueryRow(query, args...).Scan(&userID, &expires)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -230,21 +307,38 @@ func (s *Server) verifyToken(username, token string) (string, error) {
|
||||
|
||||
func (s *Server) renewToken(username, token string) (int64, error) {
|
||||
// First, verify the token exists and get the token ID
|
||||
query := `
|
||||
SELECT t.id FROM tokens t
|
||||
JOIN users u ON t.uid = u.id
|
||||
WHERE u.user = ? AND t.token = ?
|
||||
`
|
||||
// Use squirrel to build the query safely
|
||||
query, args, err := squirrel.Select("t.id").
|
||||
From("tokens t").
|
||||
Join("users u ON t.uid = u.id").
|
||||
Where(squirrel.And{
|
||||
squirrel.Eq{"u.user": username},
|
||||
squirrel.Eq{"t.token": token},
|
||||
}).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var tokenID string
|
||||
err := s.DB.QueryRow(query, username, token).Scan(&tokenID)
|
||||
err = s.DB.QueryRow(query, args...).Scan(&tokenID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Update the token's expiry time
|
||||
expires := time.Now().Add(24 * time.Hour).Unix()
|
||||
updateQuery := `UPDATE tokens SET expires = ? WHERE id = ?`
|
||||
_, err = s.DB.Exec(updateQuery, expires, tokenID)
|
||||
|
||||
// Use squirrel to build the update query safely
|
||||
updateQuery, updateArgs, err := squirrel.Update("tokens").
|
||||
Set("expires", expires).
|
||||
Where(squirrel.Eq{"id": tokenID}).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
_, err = s.DB.Exec(updateQuery, updateArgs...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -277,6 +371,11 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Check if TOTP is enabled for the user
|
||||
if !user.HasTOTP() {
|
||||
// Log the security event
|
||||
details := map[string]string{
|
||||
"reason": "TOTP not enabled for user",
|
||||
}
|
||||
s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details)
|
||||
s.sendError(w, "TOTP not enabled for user", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -284,6 +383,11 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||
// Validate the TOTP code
|
||||
valid, validErr := user.ValidateTOTPCode(req.TOTPCode)
|
||||
if validErr != nil || !valid {
|
||||
// Log the security event
|
||||
details := map[string]string{
|
||||
"reason": "Invalid TOTP code",
|
||||
}
|
||||
s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details)
|
||||
s.sendError(w, "Invalid TOTP code", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -292,10 +396,22 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||
token, expires, err := s.createToken(user.ID)
|
||||
if err != nil {
|
||||
s.Logger.Printf("Token creation error: %v", err)
|
||||
// Log the security event
|
||||
details := map[string]string{
|
||||
"reason": "Token creation error",
|
||||
"error": err.Error(),
|
||||
}
|
||||
s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details)
|
||||
s.sendError(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Log successful TOTP verification
|
||||
details := map[string]string{
|
||||
"token_expires": time.Unix(expires, 0).UTC().Format(time.RFC3339),
|
||||
}
|
||||
s.LogSecurityEvent(r, "totp_verification_success", user.ID, user.User, true, details)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(TokenResponse{
|
||||
@@ -356,8 +472,18 @@ func (s *Server) handleDatabaseCredentials(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
// Retrieve database credentials
|
||||
query := `SELECT id, host, port, name, user, password FROM database LIMIT 1`
|
||||
row := s.DB.QueryRow(query)
|
||||
// Use squirrel to build the query safely
|
||||
query, args, err := squirrel.Select("id", "host", "port", "name", "user", "password").
|
||||
From("database").
|
||||
Limit(1).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
s.Logger.Printf("Query building error: %v", err)
|
||||
s.sendError(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
row := s.DB.QueryRow(query, args...)
|
||||
|
||||
var id string
|
||||
var creds DatabaseCredentials
|
||||
|
||||
187
api/server.go
187
api/server.go
@@ -2,28 +2,134 @@ package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/data"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// client represents a client with rate limiting information
|
||||
type client struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// SecurityEvent represents a security-related event
|
||||
type SecurityEvent struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
EventType string `json:"event_type"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
RequestURI string `json:"request_uri"`
|
||||
Success bool `json:"success"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// RateLimiter manages rate limiting for clients
|
||||
type RateLimiter struct {
|
||||
clients map[string]*client
|
||||
mu sync.Mutex
|
||||
// Requests per second, burst size
|
||||
rate rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter
|
||||
func NewRateLimiter(r rate.Limit, b int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
clients: make(map[string]*client),
|
||||
rate: r,
|
||||
burst: b,
|
||||
}
|
||||
}
|
||||
|
||||
// GetLimiter returns a rate limiter for a client
|
||||
func (rl *RateLimiter) GetLimiter(ip string) *rate.Limiter {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
c, exists := rl.clients[ip]
|
||||
if !exists {
|
||||
c = &client{
|
||||
limiter: rate.NewLimiter(rl.rate, rl.burst),
|
||||
lastSeen: time.Now(),
|
||||
}
|
||||
rl.clients[ip] = c
|
||||
} else {
|
||||
c.lastSeen = time.Now()
|
||||
}
|
||||
|
||||
return c.limiter
|
||||
}
|
||||
|
||||
// CleanupClients removes old clients
|
||||
func (rl *RateLimiter) CleanupClients() {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
for ip, client := range rl.clients {
|
||||
if time.Since(client.lastSeen) > 1*time.Hour {
|
||||
delete(rl.clients, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
DB *sql.DB
|
||||
Router *http.ServeMux
|
||||
Logger *log.Logger
|
||||
Auth *data.AuthorizationService
|
||||
DB *sql.DB
|
||||
Router *http.ServeMux
|
||||
Logger *log.Logger
|
||||
Auth *data.AuthorizationService
|
||||
RateLimiter *RateLimiter
|
||||
}
|
||||
|
||||
// getClientIP extracts the client IP address from the request
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Check for X-Forwarded-For header first (for clients behind proxy)
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip != "" {
|
||||
// X-Forwarded-For can contain multiple IPs, use the first one
|
||||
ips := net.ParseIP(ip)
|
||||
if ips != nil {
|
||||
return ips.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func NewServer(db *sql.DB, logger *log.Logger) *Server {
|
||||
// Create a rate limiter with 10 requests per second and burst of 30
|
||||
rateLimiter := NewRateLimiter(10, 30)
|
||||
|
||||
s := &Server{
|
||||
DB: db,
|
||||
Router: http.NewServeMux(),
|
||||
Logger: logger,
|
||||
Auth: data.NewAuthorizationService(db),
|
||||
DB: db,
|
||||
Router: http.NewServeMux(),
|
||||
Logger: logger,
|
||||
Auth: data.NewAuthorizationService(db),
|
||||
RateLimiter: rateLimiter,
|
||||
}
|
||||
|
||||
// Start a goroutine to clean up old clients
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(1 * time.Hour)
|
||||
rateLimiter.CleanupClients()
|
||||
}
|
||||
}()
|
||||
|
||||
s.registerRoutes()
|
||||
|
||||
return s
|
||||
@@ -36,11 +142,76 @@ func (s *Server) registerRoutes() {
|
||||
s.Router.HandleFunc("GET /v1/database/credentials", s.handleDatabaseCredentials)
|
||||
}
|
||||
|
||||
// sendRateLimitExceeded sends a rate limit exceeded response
|
||||
func (s *Server) sendRateLimitExceeded(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
|
||||
response := map[string]string{
|
||||
"error": "Rate limit exceeded. Please try again later.",
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
s.Logger.Printf("Error encoding rate limit response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Get client IP
|
||||
clientIP := getClientIP(r)
|
||||
|
||||
// Get rate limiter for this client
|
||||
limiter := s.RateLimiter.GetLimiter(clientIP)
|
||||
|
||||
// Check if rate limit is exceeded
|
||||
if !limiter.Allow() {
|
||||
s.Logger.Printf("Rate limit exceeded for IP: %s, URI: %s", clientIP, r.RequestURI)
|
||||
s.sendRateLimitExceeded(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply stricter rate limiting for authentication endpoints
|
||||
if r.URL.Path == "/v1/login/password" || r.URL.Path == "/v1/login/token" || r.URL.Path == "/v1/login/totp" {
|
||||
// Use a separate limiter with lower rate for auth endpoints
|
||||
authLimiter := rate.NewLimiter(rate.Limit(1), 5) // 1 request per second, burst of 5
|
||||
if !authLimiter.Allow() {
|
||||
s.Logger.Printf("Auth rate limit exceeded for IP: %s, URI: %s", clientIP, r.RequestURI)
|
||||
s.sendRateLimitExceeded(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with the request
|
||||
s.Router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// LogSecurityEvent logs a security-related event
|
||||
func (s *Server) LogSecurityEvent(r *http.Request, eventType string, userID, username string, success bool, details map[string]string) {
|
||||
event := SecurityEvent{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
EventType: eventType,
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
IPAddress: getClientIP(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
RequestURI: r.RequestURI,
|
||||
Success: success,
|
||||
Details: details,
|
||||
}
|
||||
|
||||
// Convert to JSON for structured logging
|
||||
eventJSON, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
s.Logger.Printf("Error marshaling security event: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Log the security event
|
||||
s.Logger.Printf("SECURITY_EVENT: %s", eventJSON)
|
||||
}
|
||||
|
||||
func (s *Server) Start(addr string) error {
|
||||
s.Logger.Printf("Starting server on %s", addr)
|
||||
s.Logger.Printf("Note: This server is designed to run behind a reverse proxy that handles TLS")
|
||||
return http.ListenAndServe(addr, s)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user