package site import ( "bytes" "context" "errors" "fmt" "io" "net/http" "strconv" "strings" "time" "ruben/inventory2/internal/consts" "ruben/inventory2/internal/domains/accounts" "ruben/inventory2/internal/domains/authentication" "ruben/inventory2/internal/site/response" ) type identity struct { AccessToken string Claims authentication.AccessTokenClaims User accounts.OAuthUser Account *accounts.Account } func (s *Server) addIdentity(fn response.HandlerFunc) response.HandlerFunc { return func(r *http.Request) (response.Response, error) { r, err := s.addIdentityToRequest(r) if err != nil { return nil, err } return fn(r) } } func (s *Server) addIdentityToRequest(r *http.Request) (*http.Request, error) { ck, err := r.Cookie("access_token") if err != nil { return r, nil } ctx := r.Context() accessToken := ck.Value claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) if err != nil { if errors.Is(err, consts.ErrNotFound) { return r, nil } return r, response.Errorf("failed to load authentication details: %w", err) } if expiration.Before(time.Now()) { return r, nil } user, acct, err := s.accts.GetUserAndAccountByAccessToken(ctx, accessToken) if err != nil { return r, response.Errorf("failed to load user and account defails: %w", err) } return r.WithContext(setIdentity(ctx, identity{ AccessToken: accessToken, Claims: claims, User: user, Account: acct, })), nil } // TODO: after getting auth, consider making http handler functions take 'claims', etc, as function arguments // - then consider doing the same with authorizationAssertions // auth middleware to verify access_token cookie and set custom claims in the request context func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions ...authorizationAssertions) response.HandlerFunc { return func(r *http.Request) (response.Response, error) { ck, err := r.Cookie("access_token") if err != nil { return response.TemporaryRedirect("/"). JSON("no access_token cookie provided"), nil } ctx := r.Context() accessToken := ck.Value claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) if err != nil { if errors.Is(err, consts.ErrNotFound) { u, err := s.newLoginURL(ctx, r.URL.String()) if err != nil { return nil, response.Errorf("failed to generate login url: %w", err) } return response.TemporaryRedirect(u), nil } return nil, response.Errorf("failed to authenticate: %w", err) } 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 = s.auth.RefreshAccessToken(ctx, accessToken) if err != nil { fmt.Println("failed to refresh access token:", err) return response.TemporaryRedirect("/"). Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))). Cookie(getExpiredCookie("access_token")), nil } // 'redirect' to same url, to set the new access_token cookie return response.TemporaryRedirect(r.URL.String()). Cookie(newAccessTokenCookie(accessToken, expiration)), nil } // add identity info to request context user, acct, err := s.accts.GetUserAndAccountByAccessToken(ctx, accessToken) if err != nil { return nil, response.Errorf("failed to authorize: %w", err) } for _, as := range assertions { if res, err := as(r); res != nil || err != nil { return res, err } } return f(r.WithContext(setIdentity(ctx, identity{ AccessToken: accessToken, Claims: claims, User: user, Account: acct, }))) } } type authorizationAssertions = response.HandlerFunc // TODO: test this! func authorizeByMatchingAccountID_tmp(acctIDPathPosition int) authorizationAssertions { return func(r *http.Request) (response.Response, error) { return nil, authorizeByMatchingAccountID(r, acctIDPathPosition) } } func (s *Server) getAccessTokenClaims(r *http.Request) (authentication.AccessTokenClaims, bool) { ck, err := r.Cookie("access_token") if err != nil { return authentication.AccessTokenClaims{}, false } ctx := r.Context() claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, ck.Value) if err != nil { return authentication.AccessTokenClaims{}, false } if expiration.Before(time.Now()) { return authentication.AccessTokenClaims{}, false } return claims, true } type identityKey struct{} // stores identity in request context func setIdentity(ctx context.Context, id identity) context.Context { return context.WithValue(ctx, identityKey{}, id) } // get identity from request context func getIdentity(ctx context.Context) identity { id, _ := ctx.Value(identityKey{}).(identity) return id } func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error { pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/") if len(pathParts) < acctIDPathPosition { return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path) } part := pathParts[acctIDPathPosition-1] acctID, err := strconv.ParseInt(part, 10, 64) if err != nil { return response.NotFound(). Msgf("account does not exist: %s", part) } id := getIdentity(r.Context()) if id.Account == nil || id.Account.ID != acctID { return response.Unauthorized(). Msgf("user does not have access to account %d", acctID) } return nil }