fix: cookie handling (#654)

* feat: set cookie prefix and max age

* cookie prefix on csrf cookie

* fix: check user agent cookie in login

* update oidc pkg

* cleanup
This commit is contained in:
Livio Amstutz 2020-08-31 08:49:35 +02:00 committed by GitHub
parent 1089193faf
commit c1c85e632b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 262 additions and 205 deletions

View File

@ -114,16 +114,13 @@ func startZitadel(configPaths []string) {
func startUI(ctx context.Context, conf *Config, authRepo *auth_es.EsRepository) { func startUI(ctx context.Context, conf *Config, authRepo *auth_es.EsRepository) {
uis := ui.Create(conf.UI) uis := ui.Create(conf.UI)
if *loginEnabled { if *loginEnabled {
prefix := "" login, prefix := login.Start(conf.UI.Login, authRepo, *localDevMode)
if *localDevMode { uis.RegisterHandler(prefix, login.Handler())
prefix = ui.LoginHandler
}
uis.RegisterHandler(ui.LoginHandler, login.Start(conf.UI.Login, authRepo, prefix).Handler())
} }
if *consoleEnabled { if *consoleEnabled {
consoleHandler, err := console.Start(conf.UI.Console) consoleHandler, prefix, err := console.Start(conf.UI.Console)
logging.Log("API-AGD1f").OnError(err).Fatal("error starting console") logging.Log("API-AGD1f").OnError(err).Fatal("error starting console")
uis.RegisterHandler(ui.ConsoleHandler, consoleHandler) uis.RegisterHandler(prefix, consoleHandler)
} }
uis.Start(ctx) uis.Start(ctx)
} }
@ -148,7 +145,7 @@ func startAPI(ctx context.Context, conf *Config, authZRepo *authz_repo.EsReposit
apis.RegisterServer(ctx, auth.CreateServer(authRepo)) apis.RegisterServer(ctx, auth.CreateServer(authRepo))
} }
if *oidcEnabled { if *oidcEnabled {
op := oidc.NewProvider(ctx, conf.API.OIDC, authRepo) op := oidc.NewProvider(ctx, conf.API.OIDC, authRepo, *localDevMode)
apis.RegisterHandler("/oauth/v2", op.HttpHandler()) apis.RegisterHandler("/oauth/v2", op.HttpHandler())
} }
apis.Start(ctx) apis.Start(ctx)

View File

@ -195,6 +195,7 @@ API:
UserAgentCookieConfig: UserAgentCookieConfig:
Name: caos.zitadel.useragent Name: caos.zitadel.useragent
Domain: $ZITADEL_COOKIE_DOMAIN Domain: $ZITADEL_COOKIE_DOMAIN
MaxAge: 8760h #365*24h (1 year)
Key: Key:
EncryptionKeyID: $ZITADEL_COOKIE_KEY EncryptionKeyID: $ZITADEL_COOKIE_KEY
Cache: Cache:
@ -230,6 +231,12 @@ UI:
Key: Key:
EncryptionKeyID: $ZITADEL_CSRF_KEY EncryptionKeyID: $ZITADEL_CSRF_KEY
Development: $ZITADEL_CSRF_DEV Development: $ZITADEL_CSRF_DEV
UserAgentCookieConfig:
Name: caos.zitadel.useragent
Domain: $ZITADEL_COOKIE_DOMAIN
MaxAge: 8760h #365*24h (1 year)
Key:
EncryptionKeyID: $ZITADEL_COOKIE_KEY
Cache: Cache:
MaxAge: $ZITADEL_CACHE_MAXAGE MaxAge: $ZITADEL_CACHE_MAXAGE
SharedMaxAge: $ZITADEL_CACHE_SHARED_MAXAGE SharedMaxAge: $ZITADEL_CACHE_SHARED_MAXAGE

2
go.mod
View File

@ -16,7 +16,7 @@ require (
github.com/aws/aws-sdk-go v1.33.13 // indirect github.com/aws/aws-sdk-go v1.33.13 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
github.com/caos/logging v0.0.2 github.com/caos/logging v0.0.2
github.com/caos/oidc v0.7.3 github.com/caos/oidc v0.7.4
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
github.com/cockroachdb/cockroach-go/v2 v2.0.5 github.com/cockroachdb/cockroach-go/v2 v2.0.5
github.com/envoyproxy/protoc-gen-validate v0.4.0 github.com/envoyproxy/protoc-gen-validate v0.4.0

4
go.sum
View File

@ -71,8 +71,8 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
github.com/caos/logging v0.0.0-20191210002624-b3260f690a6a/go.mod h1:9LKiDE2ChuGv6CHYif/kiugrfEXu9AwDiFWSreX7Wp0= github.com/caos/logging v0.0.0-20191210002624-b3260f690a6a/go.mod h1:9LKiDE2ChuGv6CHYif/kiugrfEXu9AwDiFWSreX7Wp0=
github.com/caos/logging v0.0.2 h1:ebg5C/HN0ludYR+WkvnFjwSExF4wvyiWPyWGcKMYsoo= github.com/caos/logging v0.0.2 h1:ebg5C/HN0ludYR+WkvnFjwSExF4wvyiWPyWGcKMYsoo=
github.com/caos/logging v0.0.2/go.mod h1:9LKiDE2ChuGv6CHYif/kiugrfEXu9AwDiFWSreX7Wp0= github.com/caos/logging v0.0.2/go.mod h1:9LKiDE2ChuGv6CHYif/kiugrfEXu9AwDiFWSreX7Wp0=
github.com/caos/oidc v0.7.3 h1:QWxzCJLv7tZ4HfZRrgM3qzlmc+sB7dD9+x2VIvyc7Co= github.com/caos/oidc v0.7.4 h1:m98Cb+wL6aPVveNaJDgkFJGmEyyamOtO0AyOKLxXWXI=
github.com/caos/oidc v0.7.3/go.mod h1:mnuSyFmv+WSuk2C/zps445xiMU9dW384/pV4WnIS8b0= github.com/caos/oidc v0.7.4/go.mod h1:mnuSyFmv+WSuk2C/zps445xiMU9dW384/pV4WnIS8b0=
github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=

View File

@ -8,9 +8,15 @@ import (
"github.com/caos/zitadel/internal/errors" "github.com/caos/zitadel/internal/errors"
) )
const (
prefixSecure = "__Secure-"
prefixHost = "__Host-"
)
type CookieHandler struct { type CookieHandler struct {
securecookie *securecookie.SecureCookie securecookie *securecookie.SecureCookie
secureOnly bool secureOnly bool
httpOnly bool
sameSite http.SameSite sameSite http.SameSite
path string path string
maxAge int maxAge int
@ -20,6 +26,7 @@ type CookieHandler struct {
func NewCookieHandler(opts ...CookieHandlerOpt) *CookieHandler { func NewCookieHandler(opts ...CookieHandlerOpt) *CookieHandler {
c := &CookieHandler{ c := &CookieHandler{
secureOnly: true, secureOnly: true,
httpOnly: true,
sameSite: http.SameSiteLaxMode, sameSite: http.SameSiteLaxMode,
path: "/", path: "/",
} }
@ -44,6 +51,12 @@ func WithUnsecure() CookieHandlerOpt {
} }
} }
func WithNonHttpOnly() CookieHandlerOpt {
return func(c *CookieHandler) {
c.httpOnly = false
}
}
func WithSameSite(sameSite http.SameSite) CookieHandlerOpt { func WithSameSite(sameSite http.SameSite) CookieHandlerOpt {
return func(c *CookieHandler) { return func(c *CookieHandler) {
c.sameSite = sameSite c.sameSite = sameSite
@ -69,6 +82,16 @@ func WithDomain(domain string) CookieHandlerOpt {
} }
} }
func SetCookiePrefix(name, domain, path string, secureOnly bool) string {
if !secureOnly {
return name
}
if domain != "" || path != "/" {
return prefixSecure + name
}
return prefixHost + name
}
func (c *CookieHandler) GetCookieValue(r *http.Request, name string) (string, error) { func (c *CookieHandler) GetCookieValue(r *http.Request, name string) (string, error) {
cookie, err := r.Cookie(name) cookie, err := r.Cookie(name)
if err != nil { if err != nil {
@ -78,7 +101,7 @@ func (c *CookieHandler) GetCookieValue(r *http.Request, name string) (string, er
} }
func (c *CookieHandler) GetEncryptedCookieValue(r *http.Request, name string, value interface{}) error { func (c *CookieHandler) GetEncryptedCookieValue(r *http.Request, name string, value interface{}) error {
cookie, err := r.Cookie(name) cookie, err := r.Cookie(SetCookiePrefix(name, c.domain, c.path, c.secureOnly))
if err != nil { if err != nil {
return err return err
} }
@ -110,12 +133,12 @@ func (c *CookieHandler) DeleteCookie(w http.ResponseWriter, name string) {
func (c *CookieHandler) httpSet(w http.ResponseWriter, name, value string, maxage int) { func (c *CookieHandler) httpSet(w http.ResponseWriter, name, value string, maxage int) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: name, Name: SetCookiePrefix(name, c.domain, c.path, c.secureOnly),
Value: value, Value: value,
Domain: c.domain, Domain: c.domain,
Path: c.path, Path: c.path,
MaxAge: maxage, MaxAge: maxage,
HttpOnly: true, HttpOnly: c.httpOnly,
Secure: c.secureOnly, Secure: c.secureOnly,
SameSite: c.sameSite, SameSite: c.sameSite,
}) })

View File

@ -41,13 +41,13 @@ var (
remoteAddr key remoteAddr key
) )
func CopyHeadersToContext(h http.HandlerFunc) http.HandlerFunc { func CopyHeadersToContext(h http.Handler) http.Handler {
return func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), httpHeaders, r.Header) ctx := context.WithValue(r.Context(), httpHeaders, r.Header)
ctx = context.WithValue(ctx, remoteAddr, r.RemoteAddr) ctx = context.WithValue(ctx, remoteAddr, r.RemoteAddr)
r = r.WithContext(ctx) r = r.WithContext(ctx)
h(w, r) h.ServeHTTP(w, r)
} })
} }
func HeadersFromCtx(ctx context.Context) (http.Header, bool) { func HeadersFromCtx(ctx context.Context) (http.Header, bool) {

View File

@ -9,10 +9,10 @@ import (
http_utils "github.com/caos/zitadel/internal/api/http" http_utils "github.com/caos/zitadel/internal/api/http"
) )
type key int type securityKey int
const ( const (
nonceKey key = 0 nonceKey securityKey = 0
DefaultNonceLength = uint(32) DefaultNonceLength = uint(32)
) )

View File

@ -0,0 +1,103 @@
package middleware
import (
"context"
"net/http"
http_utils "github.com/caos/zitadel/internal/api/http"
"github.com/caos/zitadel/internal/config/types"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/id"
)
type cookieKey int
var (
userAgentKey cookieKey = 0
)
func UserAgentIDFromCtx(ctx context.Context) (string, bool) {
userAgentID, ok := ctx.Value(userAgentKey).(string)
return userAgentID, ok
}
type UserAgent struct {
ID string
}
type userAgentHandler struct {
cookieHandler *http_utils.CookieHandler
cookieName string
idGenerator id.Generator
nextHandler http.Handler
}
type UserAgentCookieConfig struct {
Name string
Domain string
Key *crypto.KeyConfig
MaxAge types.Duration
}
func NewUserAgentHandler(config *UserAgentCookieConfig, idGenerator id.Generator, localDevMode bool) (func(http.Handler) http.Handler, error) {
key, err := crypto.LoadKey(config.Key, config.Key.EncryptionKeyID)
if err != nil {
return nil, err
}
cookieKey := []byte(key)
opts := []http_utils.CookieHandlerOpt{
http_utils.WithEncryption(cookieKey, cookieKey),
http_utils.WithDomain(config.Domain),
http_utils.WithMaxAge(int(config.MaxAge.Seconds())),
}
if localDevMode {
opts = append(opts, http_utils.WithUnsecure())
}
return func(handler http.Handler) http.Handler {
return &userAgentHandler{
nextHandler: handler,
cookieName: config.Name,
cookieHandler: http_utils.NewCookieHandler(opts...),
idGenerator: idGenerator,
}
}, nil
}
func (ua *userAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
agent, err := ua.getUserAgent(r)
if err != nil {
agent, err = ua.newUserAgent()
}
if err == nil {
ctx := context.WithValue(r.Context(), userAgentKey, agent.ID)
r = r.WithContext(ctx)
ua.setUserAgent(w, agent)
}
ua.nextHandler.ServeHTTP(w, r)
}
func (ua *userAgentHandler) newUserAgent() (*UserAgent, error) {
agentID, err := ua.idGenerator.Next()
if err != nil {
return nil, err
}
return &UserAgent{ID: agentID}, nil
}
func (ua *userAgentHandler) getUserAgent(r *http.Request) (*UserAgent, error) {
userAgent := new(UserAgent)
err := ua.cookieHandler.GetEncryptedCookieValue(r, ua.cookieName, userAgent)
if err != nil {
return nil, errors.ThrowPermissionDenied(err, "HTTP-YULqH4", "cannot read user agent cookie")
}
return userAgent, nil
}
func (ua *userAgentHandler) setUserAgent(w http.ResponseWriter, agent *UserAgent) error {
err := ua.cookieHandler.SetEncryptedCookie(w, ua.cookieName, agent)
if err != nil {
return errors.ThrowPermissionDenied(err, "HTTP-AqgqdA", "cannot set user agent cookie")
}
return nil
}

View File

@ -1,68 +0,0 @@
package http
import (
"net/http"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/id"
)
type UserAgent struct {
ID string
}
type UserAgentHandler struct {
handler *CookieHandler
cookieName string
idGenerator id.Generator
}
type UserAgentCookieConfig struct {
Name string
Domain string
Key *crypto.KeyConfig
}
func NewUserAgentHandler(config *UserAgentCookieConfig, idGenerator id.Generator) (*UserAgentHandler, error) {
key, err := crypto.LoadKey(config.Key, config.Key.EncryptionKeyID)
if err != nil {
return nil, err
}
cookieKey := []byte(key)
handler := NewCookieHandler(
WithEncryption(cookieKey, cookieKey),
WithDomain(config.Domain),
WithUnsecure(),
)
return &UserAgentHandler{
cookieName: config.Name,
handler: handler,
idGenerator: idGenerator,
}, nil
}
func (ua *UserAgentHandler) NewUserAgent() (*UserAgent, error) {
agentID, err := ua.idGenerator.Next()
if err != nil {
return nil, err
}
return &UserAgent{ID: agentID}, nil
}
func (ua *UserAgentHandler) GetUserAgent(r *http.Request) (*UserAgent, error) {
userAgent := new(UserAgent)
err := ua.handler.GetEncryptedCookieValue(r, ua.cookieName, userAgent)
if err != nil {
return nil, errors.ThrowPermissionDenied(err, "HTTP-YULqH4", "cannot read user agent cookie")
}
return userAgent, nil
}
func (ua *UserAgentHandler) SetUserAgent(w http.ResponseWriter, agent *UserAgent) error {
err := ua.handler.SetEncryptedCookie(w, ua.cookieName, agent)
if err != nil {
return errors.ThrowPermissionDenied(err, "HTTP-AqgqdA", "cannot set user agent cookie")
}
return nil
}

View File

@ -9,13 +9,13 @@ import (
"github.com/caos/oidc/pkg/op" "github.com/caos/oidc/pkg/op"
"gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2"
"github.com/caos/zitadel/internal/auth_request/model" "github.com/caos/zitadel/internal/api/http/middleware"
"github.com/caos/zitadel/internal/errors" "github.com/caos/zitadel/internal/errors"
grant_model "github.com/caos/zitadel/internal/usergrant/model" grant_model "github.com/caos/zitadel/internal/usergrant/model"
) )
func (o *OPStorage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest, userID string) (op.AuthRequest, error) { func (o *OPStorage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest, userID string) (op.AuthRequest, error) {
userAgentID, ok := UserAgentIDFromCtx(ctx) userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
if !ok { if !ok {
return nil, errors.ThrowPreconditionFailed(nil, "OIDC-sd436", "no user agent id") return nil, errors.ThrowPreconditionFailed(nil, "OIDC-sd436", "no user agent id")
} }
@ -28,7 +28,11 @@ func (o *OPStorage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest
} }
func (o *OPStorage) AuthRequestByID(ctx context.Context, id string) (op.AuthRequest, error) { func (o *OPStorage) AuthRequestByID(ctx context.Context, id string) (op.AuthRequest, error) {
resp, err := o.repo.AuthRequestByIDCheckLoggedIn(ctx, id) userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
if !ok {
return nil, errors.ThrowPreconditionFailed(nil, "OIDC-D3g21", "no user agent id")
}
resp, err := o.repo.AuthRequestByIDCheckLoggedIn(ctx, id, userAgentID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -44,7 +48,11 @@ func (o *OPStorage) AuthRequestByCode(ctx context.Context, code string) (op.Auth
} }
func (o *OPStorage) SaveAuthCode(ctx context.Context, id, code string) error { func (o *OPStorage) SaveAuthCode(ctx context.Context, id, code string) error {
return o.repo.SaveAuthCode(ctx, id, code) userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
if !ok {
return errors.ThrowPreconditionFailed(nil, "OIDC-Dgus2", "no user agent id")
}
return o.repo.SaveAuthCode(ctx, id, code, userAgentID)
} }
func (o *OPStorage) DeleteAuthRequest(ctx context.Context, id string) error { func (o *OPStorage) DeleteAuthRequest(ctx context.Context, id string) error {
@ -52,16 +60,13 @@ func (o *OPStorage) DeleteAuthRequest(ctx context.Context, id string) error {
} }
func (o *OPStorage) CreateToken(ctx context.Context, authReq op.AuthRequest) (string, time.Time, error) { func (o *OPStorage) CreateToken(ctx context.Context, authReq op.AuthRequest) (string, time.Time, error) {
req, err := o.repo.AuthRequestByID(ctx, authReq.GetID()) app, err := o.repo.ApplicationByClientID(ctx, authReq.GetClientID())
if err != nil { if err != nil {
return "", time.Time{}, err return "", time.Time{}, err
} }
app, err := o.repo.ApplicationByClientID(ctx, req.ApplicationID) grants, err := o.repo.UserGrantsByProjectAndUserID(app.ProjectID, authReq.GetSubject())
if err != nil { scopes := append(authReq.GetScopes(), grantsToScopes(grants)...)
return "", time.Time{}, err req, _ := authReq.(*AuthRequest)
}
grants, err := o.repo.UserGrantsByProjectAndUserID(app.ProjectID, req.UserID)
scopes := append(req.Request.(*model.AuthRequestOIDC).Scopes, grantsToScopes(grants)...)
resp, err := o.repo.CreateToken(ctx, req.AgentID, req.ApplicationID, req.UserID, req.Audience, scopes, o.defaultAccessTokenLifetime) //PLANNED: lifetime from client resp, err := o.repo.CreateToken(ctx, req.AgentID, req.ApplicationID, req.UserID, req.Audience, scopes, o.defaultAccessTokenLifetime) //PLANNED: lifetime from client
if err != nil { if err != nil {
return "", time.Time{}, err return "", time.Time{}, err
@ -80,7 +85,7 @@ func grantsToScopes(grants []*grant_model.UserGrantView) []string {
} }
func (o *OPStorage) TerminateSession(ctx context.Context, userID, clientID string) error { func (o *OPStorage) TerminateSession(ctx context.Context, userID, clientID string) error {
userAgentID, ok := UserAgentIDFromCtx(ctx) userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
if !ok { if !ok {
return errors.ThrowPreconditionFailed(nil, "OIDC-fso7F", "no user agent id") return errors.ThrowPreconditionFailed(nil, "OIDC-fso7F", "no user agent id")
} }

View File

@ -1,37 +0,0 @@
package oidc
import (
"context"
"net/http"
http_utils "github.com/caos/zitadel/internal/api/http"
)
type key int
var (
userAgentKey key
)
func UserAgentIDFromCtx(ctx context.Context) (string, bool) {
userAgentID, ok := ctx.Value(userAgentKey).(string)
return userAgentID, ok
}
func UserAgentCookieHandler(cookieHandler *http_utils.UserAgentHandler, nextHandlerFunc func(http.HandlerFunc) http.HandlerFunc) func(http.HandlerFunc) http.HandlerFunc {
return func(handlerFunc http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ua, err := cookieHandler.GetUserAgent(r)
if err != nil {
ua, err = cookieHandler.NewUserAgent()
}
if err == nil {
ctx := context.WithValue(r.Context(), userAgentKey, ua.ID)
r = r.WithContext(ctx)
cookieHandler.SetUserAgent(w, ua)
}
handlerFunc(w, r)
nextHandlerFunc(handlerFunc)
}
}
}

View File

@ -2,7 +2,6 @@ package oidc
import ( import (
"context" "context"
"net/http"
"time" "time"
"github.com/caos/logging" "github.com/caos/logging"
@ -18,7 +17,7 @@ import (
type OPHandlerConfig struct { type OPHandlerConfig struct {
OPConfig *op.Config OPConfig *op.Config
StorageConfig StorageConfig StorageConfig StorageConfig
UserAgentCookieConfig *http_utils.UserAgentCookieConfig UserAgentCookieConfig *middleware.UserAgentCookieConfig
Cache *middleware.CacheConfig Cache *middleware.CacheConfig
Endpoints *EndpointConfig Endpoints *EndpointConfig
} }
@ -51,24 +50,18 @@ type OPStorage struct {
signingKeyAlgorithm string signingKeyAlgorithm string
} }
func NewProvider(ctx context.Context, config OPHandlerConfig, repo repository.Repository) op.OpenIDProvider { func NewProvider(ctx context.Context, config OPHandlerConfig, repo repository.Repository, localDevMode bool) op.OpenIDProvider {
cookieHandler, err := http_utils.NewUserAgentHandler(config.UserAgentCookieConfig, id.SonyFlakeGenerator) cookieHandler, err := middleware.NewUserAgentHandler(config.UserAgentCookieConfig, id.SonyFlakeGenerator, localDevMode)
logging.Log("OIDC-sd4fd").OnError(err).Panic("cannot user agent handler") logging.Log("OIDC-sd4fd").OnError(err).Panic("cannot user agent handler")
nextHandler := func(handlerFunc http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
middleware.NoCacheInterceptor(http_utils.CopyHeadersToContext(handlerFunc))
}
}
config.OPConfig.CodeMethodS256 = true config.OPConfig.CodeMethodS256 = true
provider, err := op.NewDefaultOP( provider, err := op.NewDefaultOP(
ctx, ctx,
config.OPConfig, config.OPConfig,
newStorage(config.StorageConfig, repo), newStorage(config.StorageConfig, repo),
op.WithHttpInterceptor( op.WithHttpInterceptors(
UserAgentCookieHandler( middleware.NoCacheInterceptor,
cookieHandler, cookieHandler,
nextHandler, http_utils.CopyHeadersToContext,
),
), ),
op.WithCustomAuthEndpoint(op.NewEndpointWithURL(config.Endpoints.Auth.Path, config.Endpoints.Auth.URL)), op.WithCustomAuthEndpoint(op.NewEndpointWithURL(config.Endpoints.Auth.Path, config.Endpoints.Auth.URL)),
op.WithCustomTokenEndpoint(op.NewEndpointWithURL(config.Endpoints.Token.Path, config.Endpoints.Token.URL)), op.WithCustomTokenEndpoint(op.NewEndpointWithURL(config.Endpoints.Token.Path, config.Endpoints.Token.URL)),

View File

@ -8,13 +8,13 @@ import (
type AuthRequestRepository interface { type AuthRequestRepository interface {
CreateAuthRequest(ctx context.Context, request *model.AuthRequest) (*model.AuthRequest, error) CreateAuthRequest(ctx context.Context, request *model.AuthRequest) (*model.AuthRequest, error)
AuthRequestByID(ctx context.Context, id string) (*model.AuthRequest, error) AuthRequestByID(ctx context.Context, id, userAgentID string) (*model.AuthRequest, error)
AuthRequestByIDCheckLoggedIn(ctx context.Context, id string) (*model.AuthRequest, error) AuthRequestByIDCheckLoggedIn(ctx context.Context, id, userAgentID string) (*model.AuthRequest, error)
AuthRequestByCode(ctx context.Context, code string) (*model.AuthRequest, error) AuthRequestByCode(ctx context.Context, code string) (*model.AuthRequest, error)
SaveAuthCode(ctx context.Context, id, code string) error SaveAuthCode(ctx context.Context, id, code, userAgentID string) error
DeleteAuthRequest(ctx context.Context, id string) error DeleteAuthRequest(ctx context.Context, id string) error
CheckLoginName(ctx context.Context, id, loginName string) error CheckLoginName(ctx context.Context, id, loginName, userAgentID string) error
SelectUser(ctx context.Context, id, userID string) error SelectUser(ctx context.Context, id, userID, userAgentID string) error
VerifyPassword(ctx context.Context, id, userID, password string, info *model.BrowserInfo) error VerifyPassword(ctx context.Context, id, userID, password, userAgentID string, info *model.BrowserInfo) error
VerifyMfaOTP(ctx context.Context, agentID, authRequestID string, code string, info *model.BrowserInfo) error VerifyMfaOTP(ctx context.Context, agentID, authRequestID, code, userAgentID string, info *model.BrowserInfo) error
} }

View File

@ -83,16 +83,16 @@ func (repo *AuthRequestRepo) CreateAuthRequest(ctx context.Context, request *mod
return request, nil return request, nil
} }
func (repo *AuthRequestRepo) AuthRequestByID(ctx context.Context, id string) (*model.AuthRequest, error) { func (repo *AuthRequestRepo) AuthRequestByID(ctx context.Context, id, userAgentID string) (*model.AuthRequest, error) {
return repo.getAuthRequest(ctx, id, false) return repo.getAuthRequestNextSteps(ctx, id, userAgentID, false)
} }
func (repo *AuthRequestRepo) AuthRequestByIDCheckLoggedIn(ctx context.Context, id string) (*model.AuthRequest, error) { func (repo *AuthRequestRepo) AuthRequestByIDCheckLoggedIn(ctx context.Context, id, userAgentID string) (*model.AuthRequest, error) {
return repo.getAuthRequest(ctx, id, true) return repo.getAuthRequestNextSteps(ctx, id, userAgentID, true)
} }
func (repo *AuthRequestRepo) SaveAuthCode(ctx context.Context, id, code string) error { func (repo *AuthRequestRepo) SaveAuthCode(ctx context.Context, id, code, userAgentID string) error {
request, err := repo.AuthRequests.GetAuthRequestByID(ctx, id) request, err := repo.getAuthRequest(ctx, id, userAgentID)
if err != nil { if err != nil {
return err return err
} }
@ -117,8 +117,8 @@ func (repo *AuthRequestRepo) DeleteAuthRequest(ctx context.Context, id string) e
return repo.AuthRequests.DeleteAuthRequest(ctx, id) return repo.AuthRequests.DeleteAuthRequest(ctx, id)
} }
func (repo *AuthRequestRepo) CheckLoginName(ctx context.Context, id, loginName string) error { func (repo *AuthRequestRepo) CheckLoginName(ctx context.Context, id, loginName, userAgentID string) error {
request, err := repo.AuthRequests.GetAuthRequestByID(ctx, id) request, err := repo.getAuthRequest(ctx, id, userAgentID)
if err != nil { if err != nil {
return err return err
} }
@ -129,8 +129,8 @@ func (repo *AuthRequestRepo) CheckLoginName(ctx context.Context, id, loginName s
return repo.AuthRequests.UpdateAuthRequest(ctx, request) return repo.AuthRequests.UpdateAuthRequest(ctx, request)
} }
func (repo *AuthRequestRepo) SelectUser(ctx context.Context, id, userID string) error { func (repo *AuthRequestRepo) SelectUser(ctx context.Context, id, userID, userAgentID string) error {
request, err := repo.AuthRequests.GetAuthRequestByID(ctx, id) request, err := repo.getAuthRequest(ctx, id, userAgentID)
if err != nil { if err != nil {
return err return err
} }
@ -142,8 +142,8 @@ func (repo *AuthRequestRepo) SelectUser(ctx context.Context, id, userID string)
return repo.AuthRequests.UpdateAuthRequest(ctx, request) return repo.AuthRequests.UpdateAuthRequest(ctx, request)
} }
func (repo *AuthRequestRepo) VerifyPassword(ctx context.Context, id, userID, password string, info *model.BrowserInfo) error { func (repo *AuthRequestRepo) VerifyPassword(ctx context.Context, id, userID, password, userAgentID string, info *model.BrowserInfo) error {
request, err := repo.AuthRequests.GetAuthRequestByID(ctx, id) request, err := repo.getAuthRequest(ctx, id, userAgentID)
if err != nil { if err != nil {
return err return err
} }
@ -153,8 +153,8 @@ func (repo *AuthRequestRepo) VerifyPassword(ctx context.Context, id, userID, pas
return repo.UserEvents.CheckPassword(ctx, userID, password, request.WithCurrentInfo(info)) return repo.UserEvents.CheckPassword(ctx, userID, password, request.WithCurrentInfo(info))
} }
func (repo *AuthRequestRepo) VerifyMfaOTP(ctx context.Context, authRequestID, userID string, code string, info *model.BrowserInfo) error { func (repo *AuthRequestRepo) VerifyMfaOTP(ctx context.Context, authRequestID, userID, code, userAgentID string, info *model.BrowserInfo) error {
request, err := repo.AuthRequests.GetAuthRequestByID(ctx, authRequestID) request, err := repo.getAuthRequest(ctx, authRequestID, userAgentID)
if err != nil { if err != nil {
return err return err
} }
@ -164,8 +164,8 @@ func (repo *AuthRequestRepo) VerifyMfaOTP(ctx context.Context, authRequestID, us
return repo.UserEvents.CheckMfaOTP(ctx, userID, code, request.WithCurrentInfo(info)) return repo.UserEvents.CheckMfaOTP(ctx, userID, code, request.WithCurrentInfo(info))
} }
func (repo *AuthRequestRepo) getAuthRequest(ctx context.Context, id string, checkLoggedIn bool) (*model.AuthRequest, error) { func (repo *AuthRequestRepo) getAuthRequestNextSteps(ctx context.Context, id, userAgentID string, checkLoggedIn bool) (*model.AuthRequest, error) {
request, err := repo.AuthRequests.GetAuthRequestByID(ctx, id) request, err := repo.getAuthRequest(ctx, id, userAgentID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -177,6 +177,17 @@ func (repo *AuthRequestRepo) getAuthRequest(ctx context.Context, id string, chec
return request, nil return request, nil
} }
func (repo *AuthRequestRepo) getAuthRequest(ctx context.Context, id, userAgentID string) (*model.AuthRequest, error) {
request, err := repo.AuthRequests.GetAuthRequestByID(ctx, id)
if err != nil {
return nil, err
}
if request.AgentID != userAgentID {
return nil, errors.ThrowPermissionDenied(nil, "EVENT-adk13", "Errors.AuthRequest.UserAgentNotCorresponding")
}
return request, nil
}
func (repo *AuthRequestRepo) checkLoginName(request *model.AuthRequest, loginName string) error { func (repo *AuthRequestRepo) checkLoginName(request *model.AuthRequest, loginName string) error {
user, err := repo.View.UserByLoginName(loginName) user, err := repo.View.UserByLoginName(loginName)
if err != nil { if err != nil {

View File

@ -28,6 +28,7 @@ type spaHandler struct {
const ( const (
envRequestPath = "/assets/environment.json" envRequestPath = "/assets/environment.json"
envDefaultDir = "/console/" envDefaultDir = "/console/"
handlerPrefix = "/console"
) )
var ( var (
@ -50,10 +51,10 @@ func (i *spaHandler) Open(name string) (http.File, error) {
return i.fileSystem.Open("/index.html") return i.fileSystem.Open("/index.html")
} }
func Start(config Config) (http.Handler, error) { func Start(config Config) (http.Handler, string, error) {
statikFS, err := fs.NewWithNamespace("console") statikFS, err := fs.NewWithNamespace("console")
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
envDir := envDefaultDir envDir := envDefaultDir
if config.EnvOverwriteDir != "" { if config.EnvOverwriteDir != "" {
@ -69,7 +70,7 @@ func Start(config Config) (http.Handler, error) {
handler := &http.ServeMux{} handler := &http.ServeMux{}
handler.Handle("/", cache(security(http.FileServer(&spaHandler{statikFS})))) handler.Handle("/", cache(security(http.FileServer(&spaHandler{statikFS}))))
handler.Handle(envRequestPath, cache(security(http.StripPrefix("/assets", http.FileServer(http.Dir(envDir)))))) handler.Handle(envRequestPath, cache(security(http.StripPrefix("/assets", http.FileServer(http.Dir(envDir))))))
return handler, nil return handler, handlerPrefix, nil
} }
func csp(zitadelDomain string) *middleware.CSP { func csp(zitadelDomain string) *middleware.CSP {

View File

@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
http_mw "github.com/caos/zitadel/internal/api/http/middleware"
"github.com/caos/zitadel/internal/auth_request/model" "github.com/caos/zitadel/internal/auth_request/model"
) )
@ -15,7 +16,8 @@ func (l *Login) getAuthRequest(r *http.Request) (*model.AuthRequest, error) {
if authRequestID == "" { if authRequestID == "" {
return nil, nil return nil, nil
} }
return l.authRepo.AuthRequestByID(r.Context(), authRequestID) userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
return l.authRepo.AuthRequestByID(r.Context(), authRequestID, userAgentID)
} }
func (l *Login) getAuthRequestAndParseData(r *http.Request, data interface{}) (*model.AuthRequest, error) { func (l *Login) getAuthRequestAndParseData(r *http.Request, data interface{}) (*model.AuthRequest, error) {

View File

@ -11,11 +11,14 @@ import (
"golang.org/x/text/language" "golang.org/x/text/language"
"github.com/caos/zitadel/internal/api/authz" "github.com/caos/zitadel/internal/api/authz"
http_utils "github.com/caos/zitadel/internal/api/http"
"github.com/caos/zitadel/internal/api/http/middleware" "github.com/caos/zitadel/internal/api/http/middleware"
auth_repository "github.com/caos/zitadel/internal/auth/repository" auth_repository "github.com/caos/zitadel/internal/auth/repository"
"github.com/caos/zitadel/internal/auth/repository/eventsourcing" "github.com/caos/zitadel/internal/auth/repository/eventsourcing"
"github.com/caos/zitadel/internal/crypto" "github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/form" "github.com/caos/zitadel/internal/form"
"github.com/caos/zitadel/internal/id"
_ "github.com/caos/zitadel/internal/ui/login/statik" _ "github.com/caos/zitadel/internal/ui/login/statik"
) )
@ -35,6 +38,7 @@ type Config struct {
LanguageCookieName string LanguageCookieName string
DefaultLanguage language.Tag DefaultLanguage language.Tag
CSRF CSRF CSRF CSRF
UserAgentCookieConfig *middleware.UserAgentCookieConfig
Cache middleware.CacheConfig Cache middleware.CacheConfig
} }
@ -46,14 +50,19 @@ type CSRF struct {
const ( const (
login = "LOGIN" login = "LOGIN"
handlerPrefix = "/login"
) )
func CreateLogin(config Config, authRepo *eventsourcing.EsRepository, prefix string) *Login { func CreateLogin(config Config, authRepo *eventsourcing.EsRepository, localDevMode bool) (*Login, string) {
login := &Login{ login := &Login{
oidcAuthCallbackURL: config.OidcAuthCallbackURL, oidcAuthCallbackURL: config.OidcAuthCallbackURL,
zitadelURL: config.ZitadelURL, zitadelURL: config.ZitadelURL,
authRepo: authRepo, authRepo: authRepo,
} }
prefix := ""
if localDevMode {
prefix = handlerPrefix
}
statikFS, err := fs.NewWithNamespace("login") statikFS, err := fs.NewWithNamespace("login")
logging.Log("CONFI-Ga21f").OnError(err).Panic("unable to create filesystem") logging.Log("CONFI-Ga21f").OnError(err).Panic("unable to create filesystem")
@ -62,10 +71,12 @@ func CreateLogin(config Config, authRepo *eventsourcing.EsRepository, prefix str
cache, err := middleware.DefaultCacheInterceptor(EndpointResources, config.Cache.MaxAge.Duration, config.Cache.SharedMaxAge.Duration) cache, err := middleware.DefaultCacheInterceptor(EndpointResources, config.Cache.MaxAge.Duration, config.Cache.SharedMaxAge.Duration)
logging.Log("CONFI-BHq2a").OnError(err).Panic("unable to create cacheInterceptor") logging.Log("CONFI-BHq2a").OnError(err).Panic("unable to create cacheInterceptor")
security := middleware.SecurityHeaders(csp(), login.cspErrorHandler) security := middleware.SecurityHeaders(csp(), login.cspErrorHandler)
login.router = CreateRouter(login, statikFS, csrf, cache, security) userAgentCookie, err := middleware.NewUserAgentHandler(config.UserAgentCookieConfig, id.SonyFlakeGenerator, localDevMode)
logging.Log("CONFI-Dvwf2").OnError(err).Panic("unable to create userAgentInterceptor")
login.router = CreateRouter(login, statikFS, csrf, cache, security, userAgentCookie)
login.renderer = CreateRenderer(prefix, statikFS, config.LanguageCookieName, config.DefaultLanguage) login.renderer = CreateRenderer(prefix, statikFS, config.LanguageCookieName, config.DefaultLanguage)
login.parser = form.NewParser() login.parser = form.NewParser()
return login return login, prefix
} }
func csp() *middleware.CSP { func csp() *middleware.CSP {
@ -81,10 +92,11 @@ func csrfInterceptor(config CSRF, errorHandler http.Handler) (func(http.Handler)
if err != nil { if err != nil {
return nil, err return nil, err
} }
path := "/"
return csrf.Protect([]byte(csrfKey), return csrf.Protect([]byte(csrfKey),
csrf.Secure(!config.Development), csrf.Secure(!config.Development),
csrf.CookieName(config.CookieName), csrf.CookieName(http_utils.SetCookiePrefix(config.CookieName, "", path, !config.Development)),
csrf.Path("/"), csrf.Path(path),
csrf.ErrorHandler(errorHandler), csrf.ErrorHandler(errorHandler),
), nil ), nil
} }

View File

@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
http_mw "github.com/caos/zitadel/internal/api/http/middleware"
"github.com/caos/zitadel/internal/auth_request/model" "github.com/caos/zitadel/internal/auth_request/model"
) )
@ -48,7 +49,8 @@ func (l *Login) handleLoginNameCheck(w http.ResponseWriter, r *http.Request) {
l.handleRegister(w, r) l.handleRegister(w, r)
return return
} }
err = l.authRepo.CheckLoginName(r.Context(), authReq.ID, data.LoginName) userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
err = l.authRepo.CheckLoginName(r.Context(), authReq.ID, data.LoginName, userAgentID)
if err != nil { if err != nil {
l.renderLogin(w, r, authReq, err) l.renderLogin(w, r, authReq, err)
return return

View File

@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
http_mw "github.com/caos/zitadel/internal/api/http/middleware"
"github.com/caos/zitadel/internal/auth_request/model" "github.com/caos/zitadel/internal/auth_request/model"
) )
@ -23,7 +24,8 @@ func (l *Login) handleMfaVerify(w http.ResponseWriter, r *http.Request) {
return return
} }
if data.MfaType == model.MfaTypeOTP { if data.MfaType == model.MfaTypeOTP {
err = l.authRepo.VerifyMfaOTP(setContext(r.Context(), authReq.UserOrgID), authReq.ID, authReq.UserID, data.Code, model.BrowserInfoFromRequest(r)) userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
err = l.authRepo.VerifyMfaOTP(setContext(r.Context(), authReq.UserOrgID), authReq.ID, authReq.UserID, data.Code, userAgentID, model.BrowserInfoFromRequest(r))
} }
if err != nil { if err != nil {
l.renderError(w, r, authReq, err) l.renderError(w, r, authReq, err)

View File

@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
http_mw "github.com/caos/zitadel/internal/api/http/middleware"
"github.com/caos/zitadel/internal/auth_request/model" "github.com/caos/zitadel/internal/auth_request/model"
) )
@ -30,7 +31,8 @@ func (l *Login) handlePasswordCheck(w http.ResponseWriter, r *http.Request) {
l.renderError(w, r, authReq, err) l.renderError(w, r, authReq, err)
return return
} }
err = l.authRepo.VerifyPassword(setContext(r.Context(), authReq.UserOrgID), authReq.ID, authReq.UserID, data.Password, model.BrowserInfoFromRequest(r)) userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
err = l.authRepo.VerifyPassword(setContext(r.Context(), authReq.UserOrgID), authReq.ID, authReq.UserID, data.Password, userAgentID, model.BrowserInfoFromRequest(r))
if err != nil { if err != nil {
l.renderPassword(w, r, authReq, err) l.renderPassword(w, r, authReq, err)
return return

View File

@ -7,16 +7,15 @@ import (
"net/http" "net/http"
"path" "path"
"github.com/caos/logging"
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
"golang.org/x/text/language"
"github.com/caos/zitadel/internal/api/http/middleware" http_mw "github.com/caos/zitadel/internal/api/http/middleware"
"github.com/caos/zitadel/internal/auth_request/model" "github.com/caos/zitadel/internal/auth_request/model"
caos_errs "github.com/caos/zitadel/internal/errors" caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/i18n" "github.com/caos/zitadel/internal/i18n"
"github.com/caos/zitadel/internal/renderer" "github.com/caos/zitadel/internal/renderer"
"github.com/caos/logging"
"golang.org/x/text/language"
) )
const ( const (
@ -135,7 +134,8 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, cookieName str
} }
func (l *Login) renderNextStep(w http.ResponseWriter, r *http.Request, authReq *model.AuthRequest) { func (l *Login) renderNextStep(w http.ResponseWriter, r *http.Request, authReq *model.AuthRequest) {
authReq, err := l.authRepo.AuthRequestByID(r.Context(), authReq.ID) userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
authReq, err := l.authRepo.AuthRequestByID(r.Context(), authReq.ID, userAgentID)
if err != nil { if err != nil {
l.renderInternalError(w, r, authReq, caos_errs.ThrowInternal(nil, "APP-sio0W", "could not get authreq")) l.renderInternalError(w, r, authReq, caos_errs.ThrowInternal(nil, "APP-sio0W", "could not get authreq"))
} }
@ -219,7 +219,7 @@ func (l *Login) getBaseData(r *http.Request, authReq *model.AuthRequest, title s
ThemeMode: l.getThemeMode(r), ThemeMode: l.getThemeMode(r),
AuthReqID: getRequestID(authReq, r), AuthReqID: getRequestID(authReq, r),
CSRF: csrf.TemplateField(r), CSRF: csrf.TemplateField(r),
Nonce: middleware.GetNonce(r), Nonce: http_mw.GetNonce(r),
} }
} }

View File

@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
http_mw "github.com/caos/zitadel/internal/api/http/middleware"
"github.com/caos/zitadel/internal/auth_request/model" "github.com/caos/zitadel/internal/auth_request/model"
) )
@ -33,7 +34,8 @@ func (l *Login) handleSelectUser(w http.ResponseWriter, r *http.Request) {
l.renderLogin(w, r, authSession, nil) l.renderLogin(w, r, authSession, nil)
return return
} }
err = l.authRepo.SelectUser(r.Context(), authSession.ID, data.UserID) userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
err = l.authRepo.SelectUser(r.Context(), authSession.ID, data.UserID, userAgentID)
if err != nil { if err != nil {
l.renderError(w, r, authSession, err) l.renderError(w, r, authSession, err)
return return

View File

@ -9,6 +9,6 @@ type Config struct {
Handler handler.Config Handler handler.Config
} }
func Start(config Config, authRepo *eventsourcing.EsRepository, pathPrefix string) *handler.Login { func Start(config Config, authRepo *eventsourcing.EsRepository, localDevMode bool) (*handler.Login, string) {
return handler.CreateLogin(config.Handler, authRepo, pathPrefix) return handler.CreateLogin(config.Handler, authRepo, localDevMode)
} }

View File

@ -157,6 +157,7 @@ Errors:
Internal: Es ist ein interner Fehler aufgetreten Internal: Es ist ein interner Fehler aufgetreten
AuthRequest: AuthRequest:
NotFound: AuthRequest konnte nicht gefunden werden NotFound: AuthRequest konnte nicht gefunden werden
UserAgentNotCorresponding: User Agent stimmt nicht überein
User: User:
NotFound: Benutzer konnte nicht gefunden werden NotFound: Benutzer konnte nicht gefunden werden
NotMatchingUserID: User stimm nicht mit User in Auth Request überein NotMatchingUserID: User stimm nicht mit User in Auth Request überein

View File

@ -159,6 +159,7 @@ Errors:
Internal: An internal error occured Internal: An internal error occured
AuthRequest: AuthRequest:
NotFound: Could not find authrequest NotFound: Could not find authrequest
UserAgentNotCorresponding: User Agent does not correspond
User: User:
NotFound: User could not be found NotFound: User could not be found
NotMatchingUserID: User and user in authrequest don't match NotMatchingUserID: User and user in authrequest don't match

View File

@ -10,8 +10,6 @@ import (
) )
const ( const (
LoginHandler = "/login"
ConsoleHandler = "/console"
uiname = "ui" uiname = "ui"
) )