feat: add database-refreshed identity authorization
This commit is contained in:
1 parent
716a8031b1
commit
c849077591
12 files changed
+1809
-15
No files matched your search
@@ -0,0 +1,177 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthorizationSnapshotLoader is the Identity module's single persistence
|
||||
// seam. A false found result means that the account does not exist.
|
||||
type AuthorizationSnapshotLoader interface {
|
||||
FindAuthorizationSnapshot(context.Context, string) (AuthorizationSnapshot, bool, error)
|
||||
}
|
||||
|
||||
// AuthorizationSnapshot contains all database-authoritative claims needed to
|
||||
// authorize one signed session.
|
||||
type AuthorizationSnapshot struct {
|
||||
Account AccountSnapshot `json:"account"`
|
||||
Organization *OrganizationSnapshot `json:"organization"`
|
||||
}
|
||||
|
||||
type AccountSnapshot struct {
|
||||
ID string `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
OrganizationID string `json:"organizationId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
SessionVersion int `json:"sessionVersion"`
|
||||
}
|
||||
|
||||
type OrganizationSnapshot struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type RejectionReason string
|
||||
|
||||
const (
|
||||
RejectionInvalidSession RejectionReason = "invalid_session"
|
||||
RejectionClientMismatch RejectionReason = "client_mismatch"
|
||||
RejectionAccountNotFound RejectionReason = "account_not_found"
|
||||
RejectionAccountDisabled RejectionReason = "account_disabled"
|
||||
RejectionSessionVersionMismatch RejectionReason = "session_version_mismatch"
|
||||
RejectionOrganizationRequired RejectionReason = "organization_required"
|
||||
RejectionOrganizationNotActive RejectionReason = "organization_not_active"
|
||||
RejectionInvalidRole RejectionReason = "invalid_role"
|
||||
)
|
||||
|
||||
var ErrUnauthenticated = errors.New("unauthenticated")
|
||||
|
||||
// UnauthenticatedError retains a diagnostic reason while allowing callers to
|
||||
// collapse all authorization denials with errors.Is(err, ErrUnauthenticated).
|
||||
type UnauthenticatedError struct {
|
||||
Reason RejectionReason
|
||||
}
|
||||
|
||||
func (err *UnauthenticatedError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", ErrUnauthenticated, err.Reason)
|
||||
}
|
||||
|
||||
func (err *UnauthenticatedError) Unwrap() error {
|
||||
return ErrUnauthenticated
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
loader AuthorizationSnapshotLoader
|
||||
secret string
|
||||
requiredClientID string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewResolver(loader AuthorizationSnapshotLoader, secret, requiredClientID string, now func() time.Time) *Resolver {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if requiredClientID == "" {
|
||||
requiredClientID = "platform"
|
||||
}
|
||||
return &Resolver{
|
||||
loader: loader,
|
||||
secret: secret,
|
||||
requiredClientID: requiredClientID,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve authenticates the signed cookie, reloads its account authorization
|
||||
// state, and returns a session whose authorization claims all come from the
|
||||
// database snapshot.
|
||||
func (resolver *Resolver) Resolve(ctx context.Context, cookieValue string) (Session, error) {
|
||||
if resolver == nil || resolver.loader == nil || resolver.secret == "" {
|
||||
return Session{}, fmt.Errorf("identity resolver is not configured")
|
||||
}
|
||||
session, err := Parse(cookieValue, resolver.secret, resolver.now())
|
||||
if err != nil {
|
||||
return Session{}, reject(RejectionInvalidSession)
|
||||
}
|
||||
if session.User.ClientID != resolver.requiredClientID {
|
||||
return Session{}, reject(RejectionClientMismatch)
|
||||
}
|
||||
|
||||
snapshot, found, err := resolver.loader.FindAuthorizationSnapshot(ctx, session.User.ID)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if !found {
|
||||
return Session{}, reject(RejectionAccountNotFound)
|
||||
}
|
||||
account := snapshot.Account
|
||||
if account.Status != "active" {
|
||||
return Session{}, reject(RejectionAccountDisabled)
|
||||
}
|
||||
if session.SessionVersion != nil && *session.SessionVersion != 0 && *session.SessionVersion != account.SessionVersion {
|
||||
return Session{}, reject(RejectionSessionVersionMismatch)
|
||||
}
|
||||
|
||||
authMode, authorities, validRole := roleClaims(account.Role)
|
||||
if !validRole {
|
||||
return Session{}, reject(RejectionInvalidRole)
|
||||
}
|
||||
if account.Role != "super_admin" {
|
||||
if account.OrganizationID == "" {
|
||||
return Session{}, reject(RejectionOrganizationRequired)
|
||||
}
|
||||
if snapshot.Organization == nil || snapshot.Organization.ID != account.OrganizationID || snapshot.Organization.Status != "active" {
|
||||
return Session{}, reject(RejectionOrganizationNotActive)
|
||||
}
|
||||
}
|
||||
|
||||
currentVersion := account.SessionVersion
|
||||
resolved := Session{
|
||||
Version: session.Version,
|
||||
AuthMode: authMode,
|
||||
IssuedAt: session.IssuedAt,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
SessionVersion: ¤tVersion,
|
||||
AccessToken: session.AccessToken,
|
||||
TokenType: session.TokenType,
|
||||
User: User{
|
||||
ID: account.ID,
|
||||
Subject: account.ID,
|
||||
Username: account.Phone,
|
||||
Phone: account.Phone,
|
||||
DisplayName: account.DisplayName,
|
||||
ClientID: resolver.requiredClientID,
|
||||
OrganizationID: account.OrganizationID,
|
||||
Role: account.Role,
|
||||
Status: account.Status,
|
||||
Authorities: authorities,
|
||||
Scope: []string{},
|
||||
},
|
||||
}
|
||||
if snapshot.Organization != nil && snapshot.Organization.ID == account.OrganizationID {
|
||||
resolved.User.OrganizationName = snapshot.Organization.Name
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func roleClaims(role string) (AuthMode, []string, bool) {
|
||||
switch role {
|
||||
case "user":
|
||||
return AuthModeUser, []string{"ROLE_USER"}, true
|
||||
case "organization_admin":
|
||||
return AuthModeAdmin, []string{"ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"}, true
|
||||
case "super_admin":
|
||||
return AuthModeAdmin, []string{"ROLE_SUPER_ADMIN", "SUPER_ADMIN"}, true
|
||||
default:
|
||||
return "", nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func reject(reason RejectionReason) error {
|
||||
return &UnauthenticatedError{Reason: reason}
|
||||
}
|
||||
Reference in new issue
Block a user