Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
437 lines
14 KiB
Go
437 lines
14 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package session
|
|
|
|
import (
|
|
"context"
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/ory/herodot"
|
|
"github.com/ory/kratos/identity"
|
|
"github.com/ory/kratos/text"
|
|
"github.com/ory/kratos/x"
|
|
"github.com/ory/x/httpx"
|
|
"github.com/ory/x/pagination/keysetpagination"
|
|
"github.com/ory/x/randx"
|
|
)
|
|
|
|
func ErrIdentityDisabled() *herodot.DefaultError {
|
|
return herodot.ErrBadRequest().WithID(text.ErrIDIdentityDisabled).WithError("identity is disabled").WithReason("This account was disabled.")
|
|
}
|
|
|
|
// lifespanProvider supplies the session lifespan, with optional per-organization overrides.
|
|
type lifespanProvider interface {
|
|
SessionLifespan(ctx context.Context) time.Duration
|
|
OrganizationSessionLifespan(ctx context.Context, orgID uuid.UUID) time.Duration
|
|
}
|
|
|
|
type refreshWindowProvider interface {
|
|
SessionRefreshMinTimeLeft(ctx context.Context) time.Duration
|
|
}
|
|
|
|
// Device corresponding to a Session
|
|
//
|
|
// swagger:model sessionDevice
|
|
type Device struct {
|
|
// Device record ID
|
|
//
|
|
// required: true
|
|
ID uuid.UUID `json:"id" faker:"-" db:"id"`
|
|
|
|
// SessionID is a helper struct field for gobuffalo.pop.
|
|
SessionID uuid.UUID `json:"-" faker:"-" db:"session_id"`
|
|
|
|
// IPAddress of the client
|
|
IPAddress *string `json:"ip_address" faker:"ptr_ipv4" db:"ip_address"`
|
|
|
|
// UserAgent of the client
|
|
UserAgent *string `json:"user_agent" faker:"-" db:"user_agent"`
|
|
|
|
// Geo Location corresponding to the IP Address
|
|
Location *string `json:"location" faker:"ptr_geo_location" db:"location"`
|
|
|
|
// Time of capture
|
|
CreatedAt time.Time `json:"-" faker:"-" db:"created_at"`
|
|
|
|
// Last updated at
|
|
UpdatedAt time.Time `json:"-" faker:"-" db:"updated_at"`
|
|
|
|
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
|
|
IdentityID *uuid.UUID `json:"-" faker:"-" db:"identity_id"`
|
|
}
|
|
|
|
func (Device) TableName() string { return "session_devices" }
|
|
|
|
// A Session
|
|
//
|
|
// swagger:model session
|
|
type Session struct {
|
|
// Session ID
|
|
//
|
|
// required: true
|
|
ID uuid.UUID `json:"id" faker:"-" db:"id"`
|
|
|
|
// Active state. If false the session is no longer active.
|
|
Active bool `json:"active" db:"active"`
|
|
|
|
// The Session Expiry
|
|
//
|
|
// When this session expires at.
|
|
ExpiresAt time.Time `json:"expires_at" db:"expires_at" faker:"time_type"`
|
|
|
|
// The Session Authentication Timestamp
|
|
//
|
|
// When this session was authenticated at. If multi-factor authentication was used this
|
|
// is the time when the last factor was authenticated (e.g. the TOTP code challenge was completed).
|
|
AuthenticatedAt time.Time `json:"authenticated_at" db:"authenticated_at" faker:"time_type"`
|
|
|
|
// AuthenticationMethod Assurance Level (AAL)
|
|
//
|
|
// The authenticator assurance level can be one of "aal1", "aal2", or "aal3". A higher number means that it is harder
|
|
// for an attacker to compromise the account.
|
|
//
|
|
// Generally, "aal1" implies that one authentication factor was used while AAL2 implies that two factors (e.g.
|
|
// password + TOTP) have been used.
|
|
//
|
|
// To learn more about these levels please head over to: https://www.ory.com/kratos/docs/concepts/credentials
|
|
AuthenticatorAssuranceLevel identity.AuthenticatorAssuranceLevel `faker:"aal_type" db:"aal" json:"authenticator_assurance_level"`
|
|
|
|
// Authentication Method References (AMR)
|
|
//
|
|
// A list of authentication methods (e.g. password, oidc, ...) used to issue this session.
|
|
AMR AuthenticationMethods `db:"authentication_methods" json:"authentication_methods"`
|
|
|
|
// The Session Issuance Timestamp
|
|
//
|
|
// When this session was issued at. Usually equal or close to `authenticated_at`.
|
|
IssuedAt time.Time `json:"issued_at" db:"issued_at" faker:"time_type"`
|
|
|
|
// The Logout Token
|
|
//
|
|
// Use this token to log out a user.
|
|
LogoutToken string `json:"-" db:"logout_token"`
|
|
|
|
// The Session Identity
|
|
//
|
|
// The identity that authenticated this session.
|
|
//
|
|
// If 2FA is required for the user, and the authentication process only solved the first factor, this field will be
|
|
// null until the session has been fully authenticated with the second factor.
|
|
Identity *identity.Identity `json:"identity" faker:"identity" db:"-" belongs_to:"identities" fk_id:"IdentityID"`
|
|
|
|
// Devices has history of all endpoints where the session was used
|
|
Devices []Device `json:"devices" faker:"-" has_many:"session_devices" fk_id:"session_id"`
|
|
|
|
// IdentityID is a helper struct field for gobuffalo.pop.
|
|
IdentityID uuid.UUID `json:"-" faker:"-" db:"identity_id"`
|
|
|
|
// CreatedAt is a helper struct field for gobuffalo.pop.
|
|
CreatedAt time.Time `json:"-" faker:"-" db:"created_at"`
|
|
|
|
// UpdatedAt is a helper struct field for gobuffalo.pop.
|
|
UpdatedAt time.Time `json:"-" faker:"-" db:"updated_at"`
|
|
|
|
// Tokenized is the tokenized (e.g. JWT) version of the session.
|
|
//
|
|
// It is only set when the `tokenize_as` query parameter was set to a valid tokenize template during calls to `/session/whoami`.
|
|
Tokenized string `json:"tokenized,omitempty" faker:"-" db:"-"`
|
|
|
|
// The Session Token
|
|
//
|
|
// The token of this session.
|
|
Token string `json:"-" db:"token"`
|
|
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
|
|
}
|
|
|
|
func (s Session) PageToken() keysetpagination.PageToken {
|
|
return keysetpagination.MapPageToken{
|
|
"id": s.ID.String(),
|
|
"created_at": s.CreatedAt.Format(x.MapPaginationDateFormat),
|
|
}
|
|
}
|
|
|
|
func (Session) DefaultPageToken() keysetpagination.PageToken {
|
|
return keysetpagination.MapPageToken{
|
|
"id": uuid.Nil.String(),
|
|
"created_at": time.Date(2200, 12, 31, 23, 59, 59, 0, time.UTC).Format(x.MapPaginationDateFormat),
|
|
}
|
|
}
|
|
|
|
func (Session) TableName() string { return "sessions" }
|
|
|
|
// AdminSession is a Session that preserves metadata_admin on the embedded
|
|
// identity during JSON marshaling. Use this type (or AdminSessions for slices)
|
|
// when writing responses from admin endpoints.
|
|
type AdminSession Session
|
|
|
|
func (s AdminSession) MarshalJSON() ([]byte, error) {
|
|
type sessionAlias Session
|
|
type adminSession struct {
|
|
sessionAlias
|
|
Identity *identity.WithAdminMetadataInJSON `json:"identity"`
|
|
}
|
|
session := Session(s)
|
|
session.Active = session.IsActive()
|
|
|
|
return json.Marshal(adminSession{
|
|
sessionAlias: sessionAlias(session),
|
|
Identity: (*identity.WithAdminMetadataInJSON)(session.Identity),
|
|
})
|
|
}
|
|
|
|
// AdminSessions converts a slice of Session to a slice of AdminSession for
|
|
// admin endpoint responses that need to include metadata_admin.
|
|
func AdminSessions(sessions []Session) []AdminSession {
|
|
result := make([]AdminSession, len(sessions))
|
|
for i, s := range sessions {
|
|
result[i] = AdminSession(s)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Session) CompletedLoginForMethod(method AuthenticationMethod) {
|
|
method.CompletedAt = time.Now().UTC()
|
|
s.AMR = append(s.AMR, method)
|
|
}
|
|
|
|
func (s *Session) CompletedLoginFor(method identity.CredentialsType, aal identity.AuthenticatorAssuranceLevel) {
|
|
s.CompletedLoginForMethod(AuthenticationMethod{Method: method, AAL: aal})
|
|
}
|
|
|
|
func (s *Session) CompletedLoginForWithProvider(method identity.CredentialsType, aal identity.AuthenticatorAssuranceLevel, providerID string, organizationID string) {
|
|
s.CompletedLoginForMethod(AuthenticationMethod{
|
|
Method: method,
|
|
AAL: aal,
|
|
Provider: providerID,
|
|
Organization: organizationID,
|
|
})
|
|
}
|
|
|
|
// CompletedLoginForOIDC appends an OIDC authentication method to the
|
|
// session and records the upstream `acr` / `amr` claim values for
|
|
// auditing. Use this when the caller has access to the upstream claims;
|
|
// otherwise use CompletedLoginForWithProvider.
|
|
func (s *Session) CompletedLoginForOIDC(method identity.CredentialsType, aal identity.AuthenticatorAssuranceLevel, providerID, organizationID, upstreamACR string, upstreamAMR []string) {
|
|
s.CompletedLoginForMethod(AuthenticationMethod{
|
|
Method: method,
|
|
AAL: aal,
|
|
Provider: providerID,
|
|
Organization: organizationID,
|
|
UpstreamACR: upstreamACR,
|
|
UpstreamAMR: upstreamAMR,
|
|
})
|
|
}
|
|
|
|
func (s *Session) AuthenticatedVia(method identity.CredentialsType) bool {
|
|
for _, authMethod := range s.AMR {
|
|
if authMethod.Method == method {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Session) SetAuthenticatorAssuranceLevel() {
|
|
if len(s.AMR) == 0 {
|
|
s.AuthenticatorAssuranceLevel = identity.NoAuthenticatorAssuranceLevel
|
|
return
|
|
}
|
|
|
|
var isAAL1, isAAL2 bool
|
|
for _, amr := range s.AMR {
|
|
switch amr.AAL {
|
|
case identity.AuthenticatorAssuranceLevel1:
|
|
isAAL1 = true
|
|
case identity.AuthenticatorAssuranceLevel2:
|
|
isAAL2 = true
|
|
if m := amr.Method; m == identity.CredentialsTypeOIDC || m == identity.CredentialsTypeSAML {
|
|
// OIDC or SAML can elevate a session to AAL2 on their own
|
|
// because the upstream provider performed the MFA itself
|
|
// (carried over via the `acr` / `amr` claims and the provider's
|
|
// AAL2ACRValues / AAL2AMRValues config).
|
|
isAAL1 = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if isAAL1 && isAAL2 {
|
|
s.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel2
|
|
} else {
|
|
// AAL1 covers every other case: single first-factor methods (password,
|
|
// OIDC without upstream MFA), an unpaired second-factor entry, or only
|
|
// recovery credentials.
|
|
s.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel1
|
|
}
|
|
}
|
|
|
|
func NewInactiveSession() *Session {
|
|
return &Session{
|
|
ID: uuid.Nil,
|
|
Token: x.OrySessionToken + randx.MustString(32, randx.AlphaNum),
|
|
LogoutToken: x.OryLogoutToken + randx.MustString(32, randx.AlphaNum),
|
|
Active: false,
|
|
AuthenticatorAssuranceLevel: identity.NoAuthenticatorAssuranceLevel,
|
|
}
|
|
}
|
|
|
|
func (s *Session) SetSessionDeviceInformation(r *http.Request) {
|
|
device := Device{
|
|
SessionID: s.ID,
|
|
IdentityID: new(s.IdentityID),
|
|
IPAddress: new(httpx.ClientIP(r)),
|
|
}
|
|
|
|
agent := r.Header["User-Agent"]
|
|
if len(agent) > 0 {
|
|
device.UserAgent = new(strings.Join(agent, " "))
|
|
}
|
|
|
|
var clientGeoLocation []string
|
|
if r.Header.Get("Cf-Ipcity") != "" {
|
|
clientGeoLocation = append(clientGeoLocation, r.Header.Get("Cf-Ipcity"))
|
|
}
|
|
if r.Header.Get("Cf-Ipcountry") != "" {
|
|
clientGeoLocation = append(clientGeoLocation, r.Header.Get("Cf-Ipcountry"))
|
|
}
|
|
loc := strings.Join(clientGeoLocation, ", ")
|
|
device.Location = &loc
|
|
|
|
s.Devices = append(s.Devices, device)
|
|
}
|
|
|
|
func (s Session) Declassified() *Session {
|
|
s.Identity = s.Identity.CopyWithoutCredentials()
|
|
return &s
|
|
}
|
|
|
|
func (s *Session) IsActive() bool {
|
|
return s.Active && s.ExpiresAt.After(time.Now()) && (s.Identity == nil || s.Identity.IsActive())
|
|
}
|
|
|
|
func (s *Session) Refresh(ctx context.Context, c lifespanProvider) *Session {
|
|
s.ExpiresAt = time.Now().Add(c.OrganizationSessionLifespan(ctx, s.OrganizationID())).UTC()
|
|
return s
|
|
}
|
|
|
|
// OrganizationID returns the organization ID that issued this session, taken
|
|
// from the first AMR entry whose Organization field parses as a UUID. Returns
|
|
// uuid.Nil if no AMR entry carries a parseable organization ID, or if s is
|
|
// nil.
|
|
func (s *Session) OrganizationID() uuid.UUID {
|
|
if s == nil {
|
|
return uuid.Nil
|
|
}
|
|
for _, m := range s.AMR {
|
|
if m.Organization == "" {
|
|
continue
|
|
}
|
|
id, err := uuid.FromString(m.Organization)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
return id
|
|
}
|
|
return uuid.Nil
|
|
}
|
|
|
|
func (s *Session) MarshalJSON() ([]byte, error) {
|
|
type ss Session
|
|
out := ss(*s)
|
|
out.Active = s.IsActive()
|
|
return json.Marshal(out)
|
|
}
|
|
|
|
func (s *Session) CanBeRefreshed(ctx context.Context, c refreshWindowProvider) bool {
|
|
return s.ExpiresAt.Add(-c.SessionRefreshMinTimeLeft(ctx)).Before(time.Now())
|
|
}
|
|
|
|
// List of (Used) AuthenticationMethods
|
|
//
|
|
// A list of authenticators which were used to authenticate the session.
|
|
//
|
|
// swagger:model sessionAuthenticationMethods
|
|
type AuthenticationMethods []AuthenticationMethod
|
|
|
|
// AuthenticationMethod identifies an authentication method
|
|
//
|
|
// A singular authenticator used during authentication / login.
|
|
//
|
|
// swagger:model sessionAuthenticationMethod
|
|
type AuthenticationMethod struct {
|
|
// The method used in this authenticator.
|
|
Method identity.CredentialsType `json:"method"`
|
|
|
|
// The AAL this method introduced.
|
|
AAL identity.AuthenticatorAssuranceLevel `json:"aal" faker:"aal_type"`
|
|
|
|
// When the authentication challenge was completed.
|
|
CompletedAt time.Time `json:"completed_at"`
|
|
|
|
// OIDC or SAML provider id used for authentication
|
|
Provider string `json:"provider,omitempty"`
|
|
|
|
// The Organization id used for authentication
|
|
Organization string `json:"organization,omitempty"`
|
|
|
|
// UpstreamACR is the `acr` claim reported by the upstream OIDC
|
|
// provider, if any. Populated only for OIDC login methods when the
|
|
// upstream ID token contained an `acr` claim.
|
|
UpstreamACR string `json:"upstream_acr,omitempty"`
|
|
|
|
// UpstreamAMR is the `amr` claim reported by the upstream OIDC
|
|
// provider, if any. Populated only for OIDC login methods when the
|
|
// upstream ID token contained an `amr` claim.
|
|
UpstreamAMR []string `json:"upstream_amr,omitempty"`
|
|
}
|
|
|
|
// Scan implements the Scanner interface.
|
|
func (n *AuthenticationMethod) Scan(value interface{}) error {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
v := fmt.Sprintf("%s", value)
|
|
if len(v) == 0 {
|
|
return nil
|
|
}
|
|
return errors.WithStack(json.Unmarshal([]byte(v), n))
|
|
}
|
|
|
|
// Value implements the driver Valuer interface.
|
|
func (n AuthenticationMethod) Value() (driver.Value, error) {
|
|
value, err := json.Marshal(n)
|
|
if err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
return string(value), nil
|
|
}
|
|
|
|
// Scan implements the Scanner interface.
|
|
func (n *AuthenticationMethods) Scan(value interface{}) error {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
v := fmt.Sprintf("%s", value)
|
|
if len(v) == 0 {
|
|
return nil
|
|
}
|
|
return errors.WithStack(json.Unmarshal([]byte(v), n))
|
|
}
|
|
|
|
// Value implements the driver Valuer interface.
|
|
func (n AuthenticationMethods) Value() (driver.Value, error) {
|
|
value, err := json.Marshal(n)
|
|
if err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
return string(value), nil
|
|
}
|