219 lines
5.1 KiB
Go
219 lines
5.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"ruben/inventory2/consts"
|
|
"ruben/inventory2/domains/accounts"
|
|
"ruben/inventory2/domains/authentication"
|
|
"ruben/inventory2/logging"
|
|
"ruben/inventory2/server/cookies"
|
|
"ruben/inventory2/server/response"
|
|
)
|
|
|
|
type (
|
|
Service struct {
|
|
log *logging.Logger
|
|
auth *authentication.Authenticator
|
|
accts *accounts.Store
|
|
}
|
|
|
|
Auth struct {
|
|
Identity Identity
|
|
Found bool
|
|
Valid bool // eg not expired
|
|
}
|
|
|
|
Identity struct {
|
|
AccessToken string
|
|
Claims authentication.AccessTokenClaims
|
|
User accounts.OAuthUser
|
|
Account *accounts.Account
|
|
}
|
|
|
|
LoginURLProviderFunc = func(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error)
|
|
|
|
AuthorizationAssertions = response.HandlerFunc
|
|
)
|
|
|
|
func NewService(
|
|
logger *logging.Logger,
|
|
auth *authentication.Authenticator,
|
|
accts *accounts.Store,
|
|
) *Service {
|
|
return &Service{
|
|
log: logger,
|
|
auth: auth,
|
|
accts: accts,
|
|
}
|
|
}
|
|
|
|
func (a *Service) GetAuthenticator() *authentication.Authenticator {
|
|
return a.auth
|
|
}
|
|
|
|
// Identify will add an Auth to the context that can then be retrieved via GetAuth.
|
|
func (a *Service) Identify(c *gin.Context) {
|
|
auth, err := a.getAuthFromRequest(c)
|
|
if auth != nil {
|
|
c.Request = c.Request.WithContext(SetAuth(c, *auth))
|
|
}
|
|
if err != nil {
|
|
c.Error(err)
|
|
c.Abort()
|
|
}
|
|
}
|
|
|
|
func (a *Service) getAuthFromRequest(c *gin.Context) (*Auth, error) {
|
|
r := c.Request
|
|
|
|
ck, err := r.Cookie("access_token")
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
accessToken := ck.Value
|
|
|
|
claims, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
|
if err != nil {
|
|
if errors.Is(err, consts.ErrNotFound) {
|
|
// need to log in, in order to access privilege pages/apis
|
|
return &Auth{}, nil
|
|
}
|
|
|
|
return nil, response.Errorf("failed to load authentication details: %w", err)
|
|
}
|
|
|
|
expiration := claims.Expiration
|
|
if expiration.Before(time.Now()) {
|
|
// need to log in, in order to access privilege pages/apis
|
|
return &Auth{
|
|
Found: true,
|
|
}, nil
|
|
}
|
|
|
|
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
|
|
if err != nil {
|
|
return nil, response.Errorf("failed to load user and account defails: %w", err)
|
|
}
|
|
|
|
return &Auth{
|
|
Found: true,
|
|
Valid: true,
|
|
Identity: Identity{
|
|
AccessToken: accessToken,
|
|
Claims: claims,
|
|
User: user,
|
|
Account: acct,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Authenticate should only be used along with and after Identify
|
|
// Typically used with response.Handler to make a gin.HandlerFunc.
|
|
func (a *Service) Authenticate(assertions ...AuthorizationAssertions) func(c *gin.Context) {
|
|
return response.Handler(a.AuthenticateHandler(assertions...))
|
|
}
|
|
|
|
// See Authenticate.
|
|
func (a *Service) AuthenticateHandler(assertions ...AuthorizationAssertions) func(c *gin.Context) (response.Response, error) {
|
|
return func(c *gin.Context) (response.Response, error) {
|
|
auth, ok := getAuth(c)
|
|
switch {
|
|
case !ok:
|
|
// TODO: would be nice if we redirected to the login page then back to the original destination
|
|
return response.SeeOther("/"), nil
|
|
case !auth.Found:
|
|
// TODO: potentially handle differently
|
|
return response.SeeOther("/"), nil
|
|
case !auth.Valid:
|
|
// TODO: potentially handle differently
|
|
return response.SeeOther("/"), nil
|
|
}
|
|
|
|
id := auth.Identity
|
|
expiration := id.Claims.Expiration
|
|
now := time.Now()
|
|
|
|
// refresh tokens, when the access token is "old enough"
|
|
|
|
// id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in.
|
|
const idTokenLifetime = 48 * time.Hour
|
|
if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) {
|
|
accessToken, expiration, err := a.auth.RefreshAccessToken(c, id.AccessToken)
|
|
if err != nil {
|
|
a.log.Warn("failed to refresh access token", "error", err)
|
|
return response.TemporaryRedirect("/").
|
|
Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))).
|
|
Cookie(cookies.Expired("access_token")), nil
|
|
}
|
|
|
|
// 'redirect' to same url, to set the new access_token cookie
|
|
return response.TemporaryRedirect(c.Request.URL.String()).
|
|
Cookie(cookies.AccessToken(accessToken, expiration)), nil
|
|
}
|
|
|
|
for _, as := range assertions {
|
|
if res, err := as(c); res != nil || err != nil {
|
|
return res, err
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
type authKey struct{}
|
|
|
|
// stores auth in context
|
|
func SetAuth(ctx context.Context, a Auth) context.Context {
|
|
c, ok := ctx.(*gin.Context)
|
|
if ok {
|
|
c.Set(authKey{}, a)
|
|
ctx = c.Request.Context()
|
|
}
|
|
return context.WithValue(ctx, authKey{}, a)
|
|
}
|
|
|
|
// get auth from context
|
|
func GetAuth(ctx context.Context) Auth {
|
|
a, _ := getAuth(ctx)
|
|
return a
|
|
}
|
|
|
|
// a wrapper on GetAuth
|
|
func GetIdentity(ctx context.Context) Identity {
|
|
a, _ := getAuth(ctx)
|
|
return a.Identity
|
|
}
|
|
|
|
func getAuth(ctx context.Context) (Auth, bool) {
|
|
a, ok := ctx.Value(authKey{}).(Auth)
|
|
if ok {
|
|
return a, true
|
|
}
|
|
|
|
c, ok := ctx.(*gin.Context)
|
|
if !ok {
|
|
return Auth{}, false
|
|
}
|
|
|
|
v, ok := c.Get(authKey{})
|
|
if !ok {
|
|
return Auth{}, false
|
|
}
|
|
|
|
a, ok = v.(Auth)
|
|
|
|
return a, ok
|
|
}
|