From b13b783d7e95d1bf486f617d362f430f22e817c9 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 4 Jan 2026 01:01:17 +1100 Subject: [PATCH] created hwsauth module --- hwsauth/authenticate.go | 54 +++++++++++++++++++++++ hwsauth/authenticator.go | 93 +++++++++++++++++++++++++++++++++++++++ hwsauth/go.mod | 18 ++++++++ hwsauth/go.sum | 34 ++++++++++++++ hwsauth/ignorepaths.go | 22 +++++++++ hwsauth/login.go | 22 +++++++++ hwsauth/logout.go | 27 ++++++++++++ hwsauth/middleware.go | 42 ++++++++++++++++++ hwsauth/model.go | 46 +++++++++++++++++++ hwsauth/protectpage.go | 43 ++++++++++++++++++ hwsauth/reauthenticate.go | 66 +++++++++++++++++++++++++++ hwsauth/refreshtokens.go | 40 +++++++++++++++++ 12 files changed, 507 insertions(+) create mode 100644 hwsauth/authenticate.go create mode 100644 hwsauth/authenticator.go create mode 100644 hwsauth/go.mod create mode 100644 hwsauth/go.sum create mode 100644 hwsauth/ignorepaths.go create mode 100644 hwsauth/login.go create mode 100644 hwsauth/logout.go create mode 100644 hwsauth/middleware.go create mode 100644 hwsauth/model.go create mode 100644 hwsauth/protectpage.go create mode 100644 hwsauth/reauthenticate.go create mode 100644 hwsauth/refreshtokens.go diff --git a/hwsauth/authenticate.go b/hwsauth/authenticate.go new file mode 100644 index 0000000..840b891 --- /dev/null +++ b/hwsauth/authenticate.go @@ -0,0 +1,54 @@ +package hwsauth + +import ( + "database/sql" + "net/http" + "time" + + "git.haelnorr.com/h/golib/jwt" + "github.com/pkg/errors" +) + +// Check the cookies for token strings and attempt to authenticate them +func (auth *Authenticator[T]) getAuthenticatedUser( + tx *sql.Tx, + w http.ResponseWriter, + r *http.Request, +) (*authenticatedModel[T], error) { + // Get token strings from cookies + atStr, rtStr := jwt.GetTokenCookies(r) + if atStr == "" && rtStr == "" { + return nil, errors.New("No token strings provided") + } + // Attempt to parse the access token + aT, err := auth.tokenGenerator.ValidateAccess(tx, atStr) + if err != nil { + // Access token invalid, attempt to parse refresh token + rT, err := auth.tokenGenerator.ValidateRefresh(tx, rtStr) + if err != nil { + return nil, errors.Wrap(err, "auth.tokenGenerator.ValidateRefresh") + } + // Refresh token valid, attempt to get a new token pair + model, err := auth.refreshAuthTokens(tx, w, r, rT) + if err != nil { + return nil, errors.Wrap(err, "auth.refreshAuthTokens") + } + // New token pair sent, return the authorized user + authUser := authenticatedModel[T]{ + model: model, + fresh: time.Now().Unix(), + } + return &authUser, nil + } + + // Access token valid + model, err := auth.load(tx, aT.SUB) + if err != nil { + return nil, errors.Wrap(err, "auth.load") + } + authUser := authenticatedModel[T]{ + model: model, + fresh: aT.Fresh, + } + return &authUser, nil +} diff --git a/hwsauth/authenticator.go b/hwsauth/authenticator.go new file mode 100644 index 0000000..22c4b14 --- /dev/null +++ b/hwsauth/authenticator.go @@ -0,0 +1,93 @@ +package hwsauth + +import ( + "database/sql" + "projectreshoot/pkg/hws" + + "git.haelnorr.com/h/golib/jwt" + "github.com/pkg/errors" + "github.com/rs/zerolog" +) + +type Authenticator[T Model] struct { + tokenGenerator *jwt.TokenGenerator + load LoadFunc[T] + conn *sql.DB + ignoredPaths []string + logger *zerolog.Logger + server *hws.Server + errorPage hws.ErrorPage + SSL bool // Use SSL for JWT tokens. Default true + TrustedHost string // TrustedHost to use for SSL verification + SecretKey string // Secret key to use for JWT tokens + AccessTokenExpiry int64 // Expiry time for Access tokens in minutes. Default 5 + RefreshTokenExpiry int64 // Expiry time for Refresh tokens in minutes. Default 1440 (1 day) + TokenFreshTime int64 // Expiry time of token freshness. Default 5 minutes + LandingPage string // Path of the desired landing page for logged in users +} + +// NewAuthenticator creates and returns a new Authenticator using the provided configuration. +// All expiry times should be provided in minutes. +// trustedHost and secretKey strings must be provided. +func NewAuthenticator[T Model]( + load LoadFunc[T], + server *hws.Server, + conn *sql.DB, + logger *zerolog.Logger, + errorPage hws.ErrorPage, +) (*Authenticator[T], error) { + if load == nil { + return nil, errors.New("No function to load model supplied") + } + if server == nil { + return nil, errors.New("No hws.Server provided") + } + if conn == nil { + return nil, errors.New("No database connection supplied") + } + if logger == nil { + return nil, errors.New("No logger provided") + } + if errorPage == nil { + return nil, errors.New("No ErrorPage provided") + } + auth := Authenticator[T]{ + load: load, + server: server, + conn: conn, + logger: logger, + errorPage: errorPage, + AccessTokenExpiry: 5, + RefreshTokenExpiry: 1440, + TokenFreshTime: 5, + SSL: true, + } + return &auth, nil +} + +// Initialise finishes the setup and prepares the Authenticator for use. +// Any custom configuration must be set before Initialise is called +func (auth *Authenticator[T]) Initialise() error { + if auth.TrustedHost == "" { + return errors.New("Trusted host must be provided") + } + if auth.SecretKey == "" { + return errors.New("Secret key cannot be blank") + } + if auth.LandingPage == "" { + return errors.New("No landing page specified") + } + tokenGen, err := jwt.CreateGenerator( + auth.AccessTokenExpiry, + auth.RefreshTokenExpiry, + auth.TokenFreshTime, + auth.TrustedHost, + auth.SecretKey, + auth.conn, + ) + if err != nil { + return errors.Wrap(err, "jwt.CreateGenerator") + } + auth.tokenGenerator = tokenGen + return nil +} diff --git a/hwsauth/go.mod b/hwsauth/go.mod new file mode 100644 index 0000000..43a4f5c --- /dev/null +++ b/hwsauth/go.mod @@ -0,0 +1,18 @@ +module git.haelnorr.com/h/golib/hwsauth + +go 1.25.5 + +require ( + git.haelnorr.com/h/golib/cookies v0.9.0 + git.haelnorr.com/h/golib/jwt v0.9.2 + github.com/pkg/errors v0.9.1 + github.com/rs/zerolog v1.34.0 +) + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + golang.org/x/sys v0.12.0 // indirect +) diff --git a/hwsauth/go.sum b/hwsauth/go.sum new file mode 100644 index 0000000..2d48923 --- /dev/null +++ b/hwsauth/go.sum @@ -0,0 +1,34 @@ +git.haelnorr.com/h/golib/cookies v0.9.0 h1:Vf+eX1prHkKuGrQon1BHY87yaPc1H+HJFRXDOV/AuWs= +git.haelnorr.com/h/golib/cookies v0.9.0/go.mod h1:y1385YExI9gLwckCVDCYVcsFXr6N7T3brJjnJD2QIuo= +git.haelnorr.com/h/golib/jwt v0.9.2 h1:l1Ow7DPGACAU54CnMP/NlZjdc4nRD1wr3xZ8a7taRvU= +git.haelnorr.com/h/golib/jwt v0.9.2/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hwsauth/ignorepaths.go b/hwsauth/ignorepaths.go new file mode 100644 index 0000000..43ac514 --- /dev/null +++ b/hwsauth/ignorepaths.go @@ -0,0 +1,22 @@ +package hwsauth + +import ( + "fmt" + "net/url" +) + +func (auth *Authenticator[T]) IgnorePaths(paths ...string) error { + for _, path := range paths { + u, err := url.Parse(path) + valid := err == nil && + u.Scheme == "" && + u.Host == "" && + u.RawQuery == "" && + u.Fragment == "" + if !valid { + return fmt.Errorf("Invalid path: '%s'", path) + } + } + auth.ignoredPaths = paths + return nil +} diff --git a/hwsauth/login.go b/hwsauth/login.go new file mode 100644 index 0000000..f007b42 --- /dev/null +++ b/hwsauth/login.go @@ -0,0 +1,22 @@ +package hwsauth + +import ( + "net/http" + + "git.haelnorr.com/h/golib/jwt" + "github.com/pkg/errors" +) + +func (auth *Authenticator[T]) Login( + w http.ResponseWriter, + r *http.Request, + model T, + rememberMe bool, +) error { + + err := jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.ID(), true, rememberMe, auth.SSL) + if err != nil { + return errors.Wrap(err, "jwt.SetTokenCookies") + } + return nil +} diff --git a/hwsauth/logout.go b/hwsauth/logout.go new file mode 100644 index 0000000..899b9bc --- /dev/null +++ b/hwsauth/logout.go @@ -0,0 +1,27 @@ +package hwsauth + +import ( + "database/sql" + "net/http" + + "git.haelnorr.com/h/golib/cookies" + "github.com/pkg/errors" +) + +func (auth *Authenticator[T]) Logout(tx *sql.Tx, w http.ResponseWriter, r *http.Request) error { + aT, rT, err := auth.getTokens(tx, r) + if err != nil { + return errors.Wrap(err, "auth.getTokens") + } + err = aT.Revoke(tx) + if err != nil { + return errors.Wrap(err, "aT.Revoke") + } + err = rT.Revoke(tx) + if err != nil { + return errors.Wrap(err, "rT.Revoke") + } + cookies.DeleteCookie(w, "access", "/") + cookies.DeleteCookie(w, "refresh", "/") + return nil +} diff --git a/hwsauth/middleware.go b/hwsauth/middleware.go new file mode 100644 index 0000000..6d4d38a --- /dev/null +++ b/hwsauth/middleware.go @@ -0,0 +1,42 @@ +package hwsauth + +import ( + "context" + "net/http" + "projectreshoot/pkg/hws" + "slices" + "time" +) + +func (auth *Authenticator[T]) Authenticate() hws.Middleware { + return auth.server.NewMiddleware(auth.authenticate()) +} + +func (auth *Authenticator[T]) authenticate() hws.MiddlewareFunc { + return func(w http.ResponseWriter, r *http.Request) (*http.Request, *hws.HWSError) { + if slices.Contains(auth.ignoredPaths, r.URL.Path) { + return r, nil + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // Start the transaction + tx, err := auth.conn.BeginTx(ctx, nil) + if err != nil { + return nil, hws.NewError(http.StatusServiceUnavailable, "Unable to start transaction", err) + } + model, err := auth.getAuthenticatedUser(tx, w, r) + if err != nil { + tx.Rollback() + auth.logger.Debug(). + Str("remote_addr", r.RemoteAddr). + Err(err). + Msg("Failed to authenticate user") + return r, nil + } + tx.Commit() + authContext := setAuthenticatedModel(r.Context(), model) + newReq := r.WithContext(authContext) + return newReq, nil + } +} diff --git a/hwsauth/model.go b/hwsauth/model.go new file mode 100644 index 0000000..4e7b8e1 --- /dev/null +++ b/hwsauth/model.go @@ -0,0 +1,46 @@ +package hwsauth + +import ( + "context" + "database/sql" +) + +type authenticatedModel[T Model] struct { + model T + fresh int64 +} + +func getNil[T Model]() T { + var result T + return result +} + +type Model interface { + ID() int +} + +type ContextLoader[T Model] func(ctx context.Context) T + +type LoadFunc[T Model] func(tx *sql.Tx, id int) (T, error) + +// Return a new context with the user added in +func setAuthenticatedModel[T Model](ctx context.Context, m *authenticatedModel[T]) context.Context { + return context.WithValue(ctx, "hwsauth context key authenticated-model", m) +} + +// Retrieve a user from the given context. Returns nil if not set +func getAuthorizedModel[T Model](ctx context.Context) *authenticatedModel[T] { + model, ok := ctx.Value("hwsauth context key authenticated-model").(*authenticatedModel[T]) + if !ok { + return nil + } + return model +} + +func (auth *Authenticator[T]) CurrentModel(ctx context.Context) T { + model := getAuthorizedModel[T](ctx) + if model == nil { + return getNil[T]() + } + return model.model +} diff --git a/hwsauth/protectpage.go b/hwsauth/protectpage.go new file mode 100644 index 0000000..39e630d --- /dev/null +++ b/hwsauth/protectpage.go @@ -0,0 +1,43 @@ +package hwsauth + +import ( + "net/http" + "time" +) + +// Checks if the model is set in the context and shows 401 page if not logged in +func (auth *Authenticator[T]) LoginReq(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + model := getAuthorizedModel[T](r.Context()) + if model == nil { + auth.errorPage(http.StatusUnauthorized, w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +// Checks if the model is set in the context and redirects them to the landing page if +// they are logged in +func (auth *Authenticator[T]) LogoutReq(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + model := getAuthorizedModel[T](r.Context()) + if model != nil { + http.Redirect(w, r, auth.LandingPage, http.StatusFound) + return + } + next.ServeHTTP(w, r) + }) +} + +func (auth *Authenticator[T]) FreshReq(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + model := getAuthorizedModel[T](r.Context()) + isFresh := time.Now().Before(time.Unix(model.fresh, 0)) + if !isFresh { + w.WriteHeader(444) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/hwsauth/reauthenticate.go b/hwsauth/reauthenticate.go new file mode 100644 index 0000000..bb3461d --- /dev/null +++ b/hwsauth/reauthenticate.go @@ -0,0 +1,66 @@ +package hwsauth + +import ( + "database/sql" + "net/http" + + "git.haelnorr.com/h/golib/jwt" + "github.com/pkg/errors" +) + +func (auth *Authenticator[T]) RefreshAuthTokens(tx *sql.Tx, w http.ResponseWriter, r *http.Request) error { + aT, rT, err := auth.getTokens(tx, r) + if err != nil { + return errors.Wrap(err, "getTokens") + } + rememberMe := map[string]bool{ + "session": false, + "exp": true, + }[aT.TTL] + // issue new tokens for the user + err = jwt.SetTokenCookies(w, r, auth.tokenGenerator, rT.SUB, true, rememberMe, auth.SSL) + if err != nil { + return errors.Wrap(err, "jwt.SetTokenCookies") + } + err = revokeTokenPair(tx, aT, rT) + if err != nil { + return errors.Wrap(err, "revokeTokenPair") + } + + return nil +} + +// Get the tokens from the request +func (auth *Authenticator[T]) getTokens( + tx *sql.Tx, + r *http.Request, +) (*jwt.AccessToken, *jwt.RefreshToken, error) { + // get the existing tokens from the cookies + atStr, rtStr := jwt.GetTokenCookies(r) + aT, err := auth.tokenGenerator.ValidateAccess(tx, atStr) + if err != nil { + return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateAccess") + } + rT, err := auth.tokenGenerator.ValidateRefresh(tx, rtStr) + if err != nil { + return nil, nil, errors.Wrap(err, "tokenGenerator.ValidateRefresh") + } + return aT, rT, nil +} + +// Revoke the given token pair +func revokeTokenPair( + tx *sql.Tx, + aT *jwt.AccessToken, + rT *jwt.RefreshToken, +) error { + err := aT.Revoke(tx) + if err != nil { + return errors.Wrap(err, "aT.Revoke") + } + err = rT.Revoke(tx) + if err != nil { + return errors.Wrap(err, "rT.Revoke") + } + return nil +} diff --git a/hwsauth/refreshtokens.go b/hwsauth/refreshtokens.go new file mode 100644 index 0000000..0418df4 --- /dev/null +++ b/hwsauth/refreshtokens.go @@ -0,0 +1,40 @@ +package hwsauth + +import ( + "database/sql" + "net/http" + + "git.haelnorr.com/h/golib/jwt" + "github.com/pkg/errors" +) + +// Attempt to use a valid refresh token to generate a new token pair +func (auth *Authenticator[T]) refreshAuthTokens( + tx *sql.Tx, + w http.ResponseWriter, + r *http.Request, + rT *jwt.RefreshToken, +) (T, error) { + model, err := auth.load(tx, rT.SUB) + if err != nil { + return getNil[T](), errors.Wrap(err, "auth.load") + } + + rememberMe := map[string]bool{ + "session": false, + "exp": true, + }[rT.TTL] + + // Set fresh to true because new tokens coming from refresh request + err = jwt.SetTokenCookies(w, r, auth.tokenGenerator, model.ID(), false, rememberMe, auth.SSL) + if err != nil { + return getNil[T](), errors.Wrap(err, "jwt.SetTokenCookies") + } + // New tokens sent, revoke the old tokens + err = rT.Revoke(tx) + if err != nil { + return getNil[T](), errors.Wrap(err, "rT.Revoke") + } + // Return the authorized user + return model, nil +}