WIP migration of API from fieldseeker-sync

This commit is contained in:
Eli Ribble 2025-12-16 16:37:53 +00:00
parent af6328faed
commit 8e325b7c77
26 changed files with 2960 additions and 102 deletions

205
auth/auth.go Normal file
View file

@ -0,0 +1,205 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/db/sql"
"github.com/aarondl/opt/omit"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
type NoCredentialsError struct{}
func (e NoCredentialsError) Error() string { return "No credentials were present in the request" }
type NoUserError struct{}
func (e NoUserError) Error() string { return "That user does not exist" }
type InvalidCredentials struct{}
func (e InvalidCredentials) Error() string { return "No username with that password exists" }
type InvalidUsername struct{}
func (e InvalidUsername) Error() string { return "That username doesn't exist" }
type AuthenticatedHandler func(http.ResponseWriter, *http.Request, *models.User)
type EnsureAuth struct {
handler AuthenticatedHandler
}
func AddUserSession(r *http.Request, user *models.User) {
id := strconv.Itoa(int(user.ID))
sessionManager.Put(r.Context(), "user_id", id)
sessionManager.Put(r.Context(), "username", user.Username)
log.Info().Str("username", user.Username).Str("user_id", id).Msg("Created new user session")
}
func GetAuthenticatedUser(r *http.Request) (*models.User, error) {
//user_id := sessionManager.GetInt(r.Context(), "user_id")
user_id_str := sessionManager.GetString(r.Context(), "user_id")
if user_id_str != "" {
user_id, err := strconv.Atoi(user_id_str)
if err != nil {
return nil, fmt.Errorf("Failed to convert user_id to int: %w", err)
}
username := sessionManager.GetString(r.Context(), "username")
log.Info().Int("user_id", user_id).Str("username", username).Msg("Current session info")
if user_id > 0 && username != "" {
return findUser(r.Context(), user_id)
}
}
// If we can't get the user from the session try to get from auth headers
username, password, ok := r.BasicAuth()
if !ok {
return nil, &NoCredentialsError{}
}
user, err := validateUser(r.Context(), username, password)
if err != nil {
return nil, err
}
AddUserSession(r, user)
return user, nil
}
func NewEnsureAuth(handlerToWrap AuthenticatedHandler) *EnsureAuth {
return &EnsureAuth{handlerToWrap}
}
func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// If this is an API request respond with a more machine-readable error state
accept := r.Header.Values("Accept")
offers := []string{"application/json", "text/html"}
content_type := NegotiateContent(accept, offers)
user, err := GetAuthenticatedUser(r)
if err != nil || user == nil {
var msg []byte
// Separate return codes for different authentication failures
if _, ok := err.(*NoCredentialsError); ok {
fmt.Println("No credentials present and no session")
w.Header().Set("WWW-Authenticate-Error", "no-credentials")
msg = []byte("Please provide credentials.\n")
} else if _, ok := err.(*NoUserError); ok {
w.Header().Set("WWW-Authenticate-Error", "invalid-credentials")
msg = []byte("Invalid credentials provided.\n")
} else if _, ok := err.(*InvalidCredentials); ok {
w.Header().Set("WWW-Authenticate-Error", "invalid-credentials")
msg = []byte("Invalid credentials provided.\n")
}
if content_type == "text/html" {
http.Redirect(w, r, "/signin?next="+r.URL.Path, http.StatusSeeOther)
return
}
w.Header().Set("WWW-Authenticate", `Basic realm="Nidus Sync"`)
w.WriteHeader(401)
w.Write(msg)
return
}
ea.handler(w, r, user)
}
func SigninUser(r *http.Request, username string, password string) (*models.User, error) {
user, err := validateUser(r.Context(), username, password)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("No matching user")
}
AddUserSession(r, user)
return user, nil
}
func SignupUser(username string, name string, password string) (*models.User, error) {
passwordHash, err := hashPassword(password)
if err != nil {
return nil, fmt.Errorf("Cannot signup user: %w", err)
}
setter := models.UserSetter{
DisplayName: omit.From(name),
PasswordHash: omit.From(passwordHash),
PasswordHashType: omit.From(enums.HashtypeBcrypt14),
Username: omit.From(username),
}
u, err := models.Users.Insert(&setter).One(context.TODO(), db.PGInstance.BobDB)
if err != nil {
return nil, fmt.Errorf("Failed to create user: %w", err)
}
log.Info().Int("ID", int(u.ID)).Str("username", u.Username).Msg("Created user")
return u, nil
}
// Helper function to translate strings into solid error types for operating on
func findUser(ctx context.Context, user_id int) (*models.User, error) {
user, err := models.FindUser(ctx, db.PGInstance.BobDB, int32(user_id))
if err != nil {
if err.Error() == "No such user" {
return nil, &NoUserError{}
} else {
//LogErrorTypeInfo(err)
//log.Error().Err(err).Msg("Unrecognized error. This should be updated in the findUser code")
return nil, err
}
}
return user, err
}
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
func validatePassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
func validateUser(ctx context.Context, username string, password string) (*models.User, error) {
passwordHash, err := hashPassword(password)
if err != nil {
return nil, fmt.Errorf("Failed to hash password: %w", err)
}
log.Info().Str("username", username).Str("password", password).Str("hash", passwordHash).Msg("Validating user")
result, err := sql.UserByUsername(username).All(ctx, db.PGInstance.BobDB)
if err != nil {
return nil, fmt.Errorf("Failed to query for user: %w", err)
}
switch len(result) {
case 0:
return nil, InvalidUsername{}
case 1:
row := result[0]
if !validatePassword(password, row.PasswordHash) {
return nil, InvalidCredentials{}
}
user := models.User{
ID: row.ID,
ArcgisAccessToken: row.ArcgisAccessToken,
ArcgisLicense: row.ArcgisLicense,
ArcgisRefreshToken: row.ArcgisRefreshToken,
ArcgisRefreshTokenExpires: row.ArcgisRefreshTokenExpires,
ArcgisRole: row.ArcgisRole,
DisplayName: row.DisplayName,
Email: row.Email,
OrganizationID: row.OrganizationID,
Username: row.Username,
}
return &user, nil
default:
return nil, errors.New("More than one matching row, this should be impossible.")
}
}

156
auth/content_negotiation.go Normal file
View file

@ -0,0 +1,156 @@
package auth
import (
"sort"
"strconv"
"strings"
)
// acceptValue represents a parsed accept header value
type acceptValue struct {
value string // the media type or value
quality float64 // quality factor (0.0 to 1.0)
specificity int // specificity level for sorting
order int // original order in the accept header
}
// NegotiateContent returns the best content to offer from a set of possible
// values, based on the preferences represented by the accept values.
func NegotiateContent(accepts []string, offers []string) string {
if len(offers) == 0 {
return ""
}
// If no accept values, return first offer
if len(accepts) == 0 {
return offers[0]
}
// Parse accept values (limit to first 32 to avoid DOS)
acceptValues := parseAcceptValues(accepts)
if len(acceptValues) > 32 {
acceptValues = acceptValues[:32]
}
// Find best match
bestMatch := ""
bestScore := -1.0
bestSpecificity := -1
bestOrder := len(offers) // use offer order as tiebreaker
for offerIdx, offer := range offers {
score, specificity := calculateScore(offer, acceptValues)
// Check if this is a better match
if score > bestScore ||
(score == bestScore && specificity > bestSpecificity) ||
(score == bestScore && specificity == bestSpecificity && offerIdx < bestOrder) {
bestMatch = offer
bestScore = score
bestSpecificity = specificity
bestOrder = offerIdx
}
}
if bestScore <= 0 {
return ""
}
return bestMatch
}
// parseAcceptValues parses accept header values into structured format
func parseAcceptValues(accepts []string) []acceptValue {
var values []acceptValue
order := 0
for _, accept := range accepts {
// Split by comma to handle multiple values in one string
parts := strings.Split(accept, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value := acceptValue{
quality: 1.0, // default quality
order: order,
}
// Split by semicolon to separate value from parameters
sections := strings.Split(part, ";")
value.value = strings.TrimSpace(sections[0])
// Parse quality parameter
for i := 1; i < len(sections); i++ {
param := strings.TrimSpace(sections[i])
if strings.HasPrefix(param, "q=") {
if q, err := strconv.ParseFloat(param[2:], 64); err == nil {
if q >= 0.0 && q <= 1.0 {
value.quality = q
}
}
break
}
}
// Calculate specificity for media types
value.specificity = calculateSpecificity(value.value)
values = append(values, value)
order++
}
}
// Sort by quality (desc), then specificity (desc), then order (asc)
sort.Slice(values, func(i, j int) bool {
if values[i].quality != values[j].quality {
return values[i].quality > values[j].quality
}
if values[i].specificity != values[j].specificity {
return values[i].specificity > values[j].specificity
}
return values[i].order < values[j].order
})
return values
}
// calculateSpecificity returns specificity level for media type matching
func calculateSpecificity(mediaType string) int {
if mediaType == "*/*" {
return 0 // least specific
}
if strings.HasSuffix(mediaType, "/*") {
return 1 // type wildcard
}
return 2 // exact match (most specific)
}
// calculateScore returns the quality score and specificity for an offer against accept values
func calculateScore(offer string, acceptValues []acceptValue) (float64, int) {
for _, accept := range acceptValues {
if matches(offer, accept.value) {
return accept.quality, accept.specificity
}
}
return 0.0, 0
}
// matches checks if an offer matches an accept value (including wildcards)
func matches(offer, accept string) bool {
if accept == "*/*" {
return true
}
if strings.HasSuffix(accept, "/*") {
// Type wildcard (e.g., "text/*")
offerType := strings.Split(offer, "/")[0]
acceptType := strings.Split(accept, "/")[0]
return offerType == acceptType
}
// Exact match
return offer == accept
}

18
auth/session.go Normal file
View file

@ -0,0 +1,18 @@
package auth
import (
"time"
"github.com/alexedwards/scs/v2"
"github.com/alexedwards/scs/pgxstore"
"github.com/Gleipnir-Technology/nidus-sync/db"
)
var sessionManager *scs.SessionManager
func NewSessionManager() *scs.SessionManager {
sessionManager = scs.New()
sessionManager.Store = pgxstore.New(db.PGInstance.PGXPool)
sessionManager.Lifetime = 24 * time.Hour
return sessionManager
}