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 }