Enhance user login functionality with options for customization
- Introduce `LoginWithOptions` method to allow for customizable login flows, enabling overrides for scopes, token expiration, and refresh token issuance. - Add `LoginOptions` struct to encapsulate optional parameters for login, improving flexibility in user authentication. - Update token handling in `issueTokens` to accommodate new options, ensuring proper management of access and refresh tokens based on user preferences. - Integrate OTP service initialization into the OpenAPI server setup for enhanced authentication capabilities.
This commit is contained in:
parent
b68660b3cd
commit
37ab2bc84b
16 changed files with 2087 additions and 60 deletions
27
go.mod
27
go.mod
|
|
@ -10,6 +10,8 @@ require (
|
|||
github.com/blang/semver v3.5.1+incompatible
|
||||
github.com/caarlos0/env/v6 v6.10.1
|
||||
github.com/dchest/captcha v1.1.0
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/docker/go-connections v0.5.0
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.1
|
||||
github.com/emersion/go-imap v1.2.1
|
||||
github.com/evanw/esbuild v0.25.4
|
||||
|
|
@ -19,6 +21,7 @@ require (
|
|||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/json-iterator/go v1.1.12
|
||||
|
|
@ -46,7 +49,7 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
github.com/JohannesKaufmann/dom v0.2.0 // indirect
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
|
|
@ -73,8 +76,6 @@ require (
|
|||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/docker/docker v28.5.2+incompatible // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
|
|
@ -96,7 +97,6 @@ require (
|
|||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-hclog v1.6.3 // indirect
|
||||
github.com/hashicorp/go-plugin v1.6.3 // indirect
|
||||
|
|
@ -122,6 +122,7 @@ require (
|
|||
github.com/mattn/go-sqlite3 v1.14.28 // indirect
|
||||
github.com/miekg/dns v1.1.66 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
|
|
@ -163,11 +164,14 @@ require (
|
|||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
golang.org/x/arch v0.17.0 // indirect
|
||||
golang.org/x/image v0.29.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
|
|
@ -175,10 +179,11 @@ require (
|
|||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
|
||||
google.golang.org/grpc v1.72.1 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||
google.golang.org/grpc v1.75.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
|
||||
// go env -w GOPRIVATE=github.com/yaoapp/*
|
||||
|
|
|
|||
73
go.sum
73
go.sum
|
|
@ -1,5 +1,8 @@
|
|||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ=
|
||||
github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 h1:mklaPbT4f/EiDr1Q+zPrEt9lgKAkVrIBtWf33d9GpVA=
|
||||
|
|
@ -55,6 +58,8 @@ github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCN
|
|||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II=
|
||||
github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
|
|
@ -64,6 +69,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
|
|||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
|
|
@ -161,6 +168,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
|
|
@ -243,6 +252,12 @@ github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
|
|||
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
|
@ -250,6 +265,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ=
|
||||
github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
|
||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.1 h1:RKWQW7wTgYAY2fU9S+9LaJ9OwRPbRc0I17tlT7nDmAY=
|
||||
|
|
@ -296,8 +313,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
|
||||
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||
|
|
@ -377,20 +394,26 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
|||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
|
@ -486,6 +509,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
|
@ -496,14 +521,18 @@ golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
|||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
|
||||
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
|
||||
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
@ -521,4 +550,6 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
|
|
|
|||
|
|
@ -179,6 +179,11 @@ func (s *Service) GetStore() store.Store {
|
|||
return s.store
|
||||
}
|
||||
|
||||
// GetKeyPrefix returns the key prefix used for store keys (e.g. "yao_:")
|
||||
func (s *Service) GetKeyPrefix() string {
|
||||
return s.prefix
|
||||
}
|
||||
|
||||
// GetSecurityConfig returns the security configuration for the service
|
||||
func (s *Service) GetSecurityConfig() types.SecurityConfig {
|
||||
if s.config == nil {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/yaoapp/yao/openapi/oauth"
|
||||
"github.com/yaoapp/yao/openapi/oauth/acl"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/openapi/otp"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
"github.com/yaoapp/yao/openapi/sandbox"
|
||||
"github.com/yaoapp/yao/openapi/team"
|
||||
|
|
@ -86,6 +87,9 @@ func Load(appConfig config.Config) (*OpenAPI, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize OTP service (shares the OAuth store)
|
||||
otp.NewService(oauthService.GetStore(), oauthService.GetKeyPrefix())
|
||||
|
||||
// Create the OpenAPI server
|
||||
Server = &OpenAPI{Config: &config, OAuth: oauthService}
|
||||
return Server, nil
|
||||
|
|
@ -160,6 +164,9 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
|
|||
// App handlers (menu, etc.)
|
||||
app.Attach(group.Group("/app"), openapi.OAuth)
|
||||
|
||||
// OTP handlers (passwordless authentication)
|
||||
otp.Attach(group.Group("/otp"), openapi.OAuth)
|
||||
|
||||
// Sandbox handlers (VNC proxy for visual browser automation)
|
||||
sandbox.SetPathPrefix(baseURL)
|
||||
sandbox.Attach(group.Group("/sandbox"), openapi.OAuth)
|
||||
|
|
|
|||
334
openapi/otp/DESIGN.md
Normal file
334
openapi/otp/DESIGN.md
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
# OTP Passwordless Authentication
|
||||
|
||||
## Overview
|
||||
|
||||
OTP (One-Time Password) provides passwordless authentication via magic links.
|
||||
AI or system generates a short link `https://host/<prefix>/v/<code>`, user clicks it,
|
||||
CUI verifies the code against the backend, backend issues tokens, and frontend redirects.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
AI/System Yao Backend CUI Frontend
|
||||
| | |
|
||||
|-- otp.Create(params) ------->| |
|
||||
|<---- code (nanoid 12) -------| |
|
||||
| | |
|
||||
| (compose link, send to user) | |
|
||||
| | |
|
||||
| | GET /<prefix>/v/<code> |
|
||||
| |<-------------------------------|
|
||||
| | |
|
||||
| | POST /api/otp/login {code} |
|
||||
| |<-------------------------------|
|
||||
| | |
|
||||
| |-- otp.Login(code) ------------>|
|
||||
| | ├─ Verify code in store |
|
||||
| | ├─ Resolve identity |
|
||||
| | ├─ LoginByTeamID → tokens |
|
||||
| | └─ SendLoginCookies |
|
||||
| | |
|
||||
| | {redirect} ----------------->|
|
||||
| | |
|
||||
| | window.location = redirect
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Four clean APIs**: Create, Verify, Login, Revoke — each with a single responsibility.
|
||||
2. **Verify is pure**: returns stored payload, no side effects.
|
||||
Login is the full flow: verify + identity resolution + token issuance.
|
||||
3. **Package location `openapi/otp/`**: can freely import `user` and `oauth`.
|
||||
Dependency chain is one-directional: `openapi/otp → user → oauth` (no cycles).
|
||||
4. **Shared store, unified key namespace**: reuses OAuth's store.Store,
|
||||
keys under `{prefix}oauth:otp:{code}` alongside refresh_token/access_token.
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
openapi/
|
||||
otp/
|
||||
DESIGN.md ← this file
|
||||
otp.go ← Service, Payload, NewService
|
||||
generate.go ← Create (nanoid + collision check + store.Set)
|
||||
verify.go ← Verify (store.Get + type coercion)
|
||||
revoke.go ← Revoke (store.Del)
|
||||
login.go ← Login (Verify + resolve identity + issue tokens)
|
||||
handler.go ← GinOTPCreate + GinOTPLogin HTTP handlers + Attach(group, oauth)
|
||||
process.go ← Yao processors: otp.Create, otp.Verify, otp.Login, otp.Revoke
|
||||
|
||||
openapi.go ← init OTP service in Load(), register route in Attach()
|
||||
```
|
||||
|
||||
```
|
||||
cui/packages/cui/
|
||||
openapi/user/auth.ts ← add OTPLogin method
|
||||
pages/auth/v/$.tsx ← OTP verification page (route: /v/<code>)
|
||||
```
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
openapi/otp
|
||||
├── imports user (LoginByTeamID, LoginWithOptions, SendLoginCookies)
|
||||
├── imports oauth (OAuth.GetUserProvider, OAuth.GetStore for identity resolution)
|
||||
└── imports store (store.Store for code persistence)
|
||||
|
||||
user
|
||||
└── imports oauth (existing, unchanged)
|
||||
```
|
||||
|
||||
One-directional: `openapi/otp → user → oauth`. No cycles.
|
||||
|
||||
## Data Structures
|
||||
|
||||
### Payload (stored in store)
|
||||
|
||||
```go
|
||||
type Payload struct {
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
MemberID string `json:"member_id,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Redirect string `json:"redirect"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### GenerateParams
|
||||
|
||||
```go
|
||||
type GenerateParams struct {
|
||||
TeamID string
|
||||
MemberID string
|
||||
UserID string
|
||||
ExpiresIn int // seconds, default 24h
|
||||
Redirect string // required
|
||||
Scope string // optional, space-separated
|
||||
}
|
||||
```
|
||||
|
||||
## Store Key Format
|
||||
|
||||
```
|
||||
{prefix}oauth:otp:{code}
|
||||
```
|
||||
|
||||
Example: `yao_:oauth:otp:abc123def456`
|
||||
|
||||
Consistent with existing OAuth keys:
|
||||
- `{prefix}oauth:refresh_token:{token}`
|
||||
- `{prefix}oauth:access_token:{token}`
|
||||
- `{prefix}oauth:otp:{code}`
|
||||
|
||||
NanoID: 12 chars, alphabet `23456789abcdefghjkmnpqrstuvwxyz` (no ambiguous chars).
|
||||
Collision check: retry up to 5 times.
|
||||
|
||||
## Processor Interface
|
||||
|
||||
Four processors, CRUD-style naming.
|
||||
|
||||
### otp.Create
|
||||
|
||||
```javascript
|
||||
code = Process("otp.Create", {
|
||||
"user_id": "user_xxx", // required (or member_id)
|
||||
"team_id": "team_xxx", // optional
|
||||
"member_id": "member_xxx", // optional; when set, team_id is required
|
||||
"expires_in": 86400, // optional; seconds, default 24h
|
||||
"redirect": "/chat", // required; target path after login
|
||||
"scope": "read write" // optional; space-separated scopes
|
||||
})
|
||||
// returns: "abc123def456" (string)
|
||||
// developer composes the full link: `${host}/${prefix}/v/${code}`
|
||||
```
|
||||
|
||||
Single map argument. Returns code string.
|
||||
|
||||
### otp.Verify
|
||||
|
||||
```javascript
|
||||
payload = Process("otp.Verify", "abc123def456")
|
||||
// returns: {
|
||||
// "team_id": "team_xxx",
|
||||
// "member_id": "member_xxx",
|
||||
// "user_id": "user_xxx",
|
||||
// "redirect": "/chat",
|
||||
// "scope": "read write"
|
||||
// }
|
||||
```
|
||||
|
||||
Pure validation. Returns stored Payload. Does NOT consume code (valid within TTL).
|
||||
Use case: inspect payload before login, or use OTP for non-login purposes.
|
||||
|
||||
### otp.Login
|
||||
|
||||
```javascript
|
||||
result = Process("otp.Login", "abc123def456", "zh-CN")
|
||||
// returns: {
|
||||
// "access_token": "Bearer ...",
|
||||
// "id_token": "eyJ...",
|
||||
// "redirect": "/chat",
|
||||
// "expires_in": 3600,
|
||||
// ...
|
||||
// }
|
||||
```
|
||||
|
||||
Full login flow: verify code -> resolve identity -> issue tokens.
|
||||
- args[0]: code (string, required)
|
||||
- args[1]: locale (string, optional)
|
||||
|
||||
Internally:
|
||||
1. Verify(code) -> Payload
|
||||
2. Resolve identity (member_id -> user_id if needed)
|
||||
3. LoginByTeamID or LoginWithOptions (when scope override)
|
||||
4. Return LoginResponse + redirect
|
||||
|
||||
Does NOT set HTTP cookies (no gin.Context). The HTTP handler wraps this
|
||||
and additionally calls SendLoginCookies.
|
||||
|
||||
### otp.Revoke
|
||||
|
||||
```javascript
|
||||
Process("otp.Revoke", "abc123def456")
|
||||
// returns: null
|
||||
```
|
||||
|
||||
Immediately removes code from store. Silent on missing/expired.
|
||||
|
||||
## HTTP APIs
|
||||
|
||||
### POST /api/otp/create (protected)
|
||||
|
||||
Requires authentication (OpenAPI Guard). Permission managed by Scope/ACL.
|
||||
The caller's `team_id` is forced from the authenticated identity; request body `team_id` is ignored.
|
||||
The `member_id` must belong to the caller's team.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"member_id": "member_xxx",
|
||||
"user_id": "user_xxx",
|
||||
"expires_in": 86400,
|
||||
"redirect": "/chat",
|
||||
"scope": "read write"
|
||||
}
|
||||
```
|
||||
|
||||
**Success (200):**
|
||||
```json
|
||||
{ "code": "abc123def456" }
|
||||
```
|
||||
|
||||
**Errors:** 400 (missing fields), 403 (member not in team), 500 (internal).
|
||||
|
||||
### POST /api/otp/login (public)
|
||||
|
||||
Public endpoint (no auth guard). The OTP code itself is the credential.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "code": "abc123def456", "locale": "zh-CN" }
|
||||
```
|
||||
|
||||
**Success (200):**
|
||||
```json
|
||||
{ "redirect": "/chat" }
|
||||
```
|
||||
Cookies set: `access_token`, `refresh_token`, `session_id`.
|
||||
|
||||
**Errors:** 400 (missing code), 401 (invalid/expired), 500 (internal).
|
||||
|
||||
### Handler Flows (handler.go)
|
||||
|
||||
#### GinOTPCreate
|
||||
```
|
||||
1. authorized.GetInfo(c) -> authInfo (teamID, userID)
|
||||
2. Bind JSON -> request body
|
||||
3. Force teamID from authInfo
|
||||
4. Validate member belongs to team
|
||||
5. service.Create(params) -> code
|
||||
6. Respond {code}
|
||||
```
|
||||
|
||||
#### GinOTPLogin
|
||||
```
|
||||
1. Bind JSON -> {code, locale}
|
||||
2. service.Login(code, locale) -> LoginResponse + Payload
|
||||
3. sessionID = utils.GetSessionID(c) or generateSessionID()
|
||||
4. user.SendLoginCookies(c, loginResp, sessionID)
|
||||
5. Respond {redirect: payload.Redirect}
|
||||
```
|
||||
|
||||
The handlers are thin — business logic lives in the Service methods.
|
||||
|
||||
## Scope Override (user package change)
|
||||
|
||||
When `payload.Scope` is non-empty, `LoginByTeamID` cannot be used directly
|
||||
because it resolves scopes internally. Add one function to `user` package:
|
||||
|
||||
```go
|
||||
// user/types.go
|
||||
type LoginOptions struct {
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
// user/login.go
|
||||
func LoginWithOptions(userid, teamID string, loginCtx *LoginContext, opts *LoginOptions) (*LoginResponse, error)
|
||||
```
|
||||
|
||||
Same logic as `LoginByTeamID`, uses `opts.Scopes` when non-nil.
|
||||
This is the **only** change to `user` package.
|
||||
|
||||
## Initialization (openapi.go)
|
||||
|
||||
In `Load()`:
|
||||
```go
|
||||
otp.NewService(oauth.OAuth.GetStore(), oauth.OAuth.GetPrefix())
|
||||
```
|
||||
|
||||
Note: Since oauth.Service.prefix is private, the OTP service constructs
|
||||
the prefix independently using `share.App.Prefix` for consistency.
|
||||
|
||||
In `Attach()`:
|
||||
```go
|
||||
otp.Attach(group.Group("/otp"), openapi.OAuth)
|
||||
```
|
||||
|
||||
## CUI Page (pages/auth/v/$.tsx)
|
||||
|
||||
```
|
||||
1. Extract code from URL path: /v/<code>
|
||||
2. Call userClient.auth.OTPLogin(code, locale)
|
||||
3. On success:
|
||||
- Call GetProfile() to get UserInfo (cookies already set by backend)
|
||||
- AfterLogin(global, { user: profileData, entry: redirect })
|
||||
- window.location.href = redirect
|
||||
4. On error: show error UI with "Go Back" button
|
||||
```
|
||||
|
||||
### auth.ts Addition
|
||||
|
||||
```typescript
|
||||
async OTPLogin(code: string, locale?: string): Promise<ApiResponse<{ redirect: string }>> {
|
||||
return this.api.Post<{ redirect: string }>('/otp/login', { code, locale: locale || '' })
|
||||
}
|
||||
```
|
||||
|
||||
## Test Plan
|
||||
|
||||
- **Unit tests** (openapi/otp package):
|
||||
- Create: produces 12-char code, stores payload, respects TTL
|
||||
- Create: validates required fields (user_id/member_id, redirect)
|
||||
- Create: handles collision retry
|
||||
- Verify: returns payload, rejects expired/invalid/empty
|
||||
- Verify: does NOT consume code (multi-verify within TTL)
|
||||
- Revoke: removes code, silent on missing
|
||||
- Login: full flow with valid code returns tokens
|
||||
- Login: rejects invalid code
|
||||
- **Handler tests**:
|
||||
- POST /api/otp/create with valid auth -> 200 + code
|
||||
- POST /api/otp/create without auth -> 401
|
||||
- POST /api/otp/create with cross-team member -> 403
|
||||
- POST /api/otp/login with valid code -> 200 + cookies + redirect
|
||||
- POST /api/otp/login with invalid code -> 401
|
||||
- POST /api/otp/login with missing code -> 400
|
||||
101
openapi/otp/README.md
Normal file
101
openapi/otp/README.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# OTP — Passwordless Authentication
|
||||
|
||||
One-time password (OTP) module for passwordless login. An authorized caller generates a short-lived code bound to a user/member and a redirect URL. The recipient opens `/v/<code>` in a browser to authenticate without credentials.
|
||||
|
||||
## Process
|
||||
|
||||
| Process | Args | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `otp.Create` | `params` (map) | code (string) | Generate an OTP code |
|
||||
| `otp.Verify` | `code` | payload (map) | Look up a code without consuming it |
|
||||
| `otp.Login` | `code`, `locale?` | LoginResult | Verify code, issue access token, optionally consume |
|
||||
| `otp.Revoke` | `code` | nil | Delete a code immediately |
|
||||
|
||||
### otp.Create
|
||||
|
||||
```
|
||||
yao run otp.Create '::{"team_id":"T1","member_id":"M1","redirect":"/dashboard"}'
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `team_id` | string | When `member_id` set | — | Team context |
|
||||
| `member_id` | string | Either this or `user_id` | — | Target member (resolved to user_id at login) |
|
||||
| `user_id` | string | Either this or `member_id` | — | Target user |
|
||||
| `redirect` | string | Yes | — | Post-login redirect URL |
|
||||
| `expires_in` | int | No | 86400 | Code TTL in seconds |
|
||||
| `token_expires_in` | int | No | system default | Access token lifetime override (seconds) |
|
||||
| `scope` | string | No | — | Space-separated scopes for the issued token |
|
||||
| `consume` | bool | No | true | Revoke code after first login |
|
||||
|
||||
### otp.Verify
|
||||
|
||||
```
|
||||
yao run otp.Verify abc123def456
|
||||
```
|
||||
|
||||
Returns the stored payload without consuming the code.
|
||||
|
||||
### otp.Login
|
||||
|
||||
```
|
||||
yao run otp.Login abc123def456 en-US
|
||||
```
|
||||
|
||||
Verifies the code, resolves identity (`member_id` → `user_id` if needed), issues an access token (no refresh token), and returns `LoginResult`. Consumes the code if `consume` is true.
|
||||
|
||||
### otp.Revoke
|
||||
|
||||
```
|
||||
yao run otp.Revoke abc123def456
|
||||
```
|
||||
|
||||
Deletes the code. Silent if the code does not exist.
|
||||
|
||||
## HTTP API
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/otp/create` | Bearer token (team context) | Create an OTP code |
|
||||
| `POST` | `/otp/login` | Public | Verify code, set session cookies |
|
||||
|
||||
### POST /otp/create
|
||||
|
||||
Requires a valid access token with team context. The `team_id` is forced from the caller's identity. Validates that the target user/member belongs to the same team.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{"member_id": "M1", "redirect": "/agents/keeper/entry/xxx"}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"code": "abc123def456"}
|
||||
```
|
||||
|
||||
### POST /otp/login
|
||||
|
||||
Public endpoint. Checks for an existing valid session first — if found, returns `already_logged_in` without issuing new tokens. Otherwise performs login and sets `access_token` cookie (no `refresh_token`).
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{"code": "abc123def456", "locale": "en-US"}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"status": "success", "redirect": "/agents/keeper/entry/xxx"}
|
||||
```
|
||||
|
||||
Status is either `success` (new session) or `already_logged_in` (existing session).
|
||||
|
||||
## Security
|
||||
|
||||
- Codes are 12-char NanoID (`[2-9a-hjkmnp-z]`), ~62 bits of entropy
|
||||
- Default TTL: 24 hours
|
||||
- Codes are single-use by default (`consume: true`)
|
||||
- No refresh token issued — access token only
|
||||
- `POST /otp/create` enforces team membership validation
|
||||
- `team_id` is always derived from the caller's token, not the request body
|
||||
70
openapi/otp/generate.go
Normal file
70
openapi/otp/generate.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package otp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
nanoid "github.com/matoous/go-nanoid/v2"
|
||||
)
|
||||
|
||||
// Create generates a new OTP code, stores the payload, and returns the code.
|
||||
// It validates required fields and retries on NanoID collision.
|
||||
func (s *Service) Create(params *GenerateParams) (string, error) {
|
||||
if err := validateCreateParams(params); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"redirect": params.Redirect,
|
||||
"consume": params.Consume,
|
||||
}
|
||||
if params.TeamID != "" {
|
||||
data["team_id"] = params.TeamID
|
||||
}
|
||||
if params.MemberID != "" {
|
||||
data["member_id"] = params.MemberID
|
||||
}
|
||||
if params.UserID != "" {
|
||||
data["user_id"] = params.UserID
|
||||
}
|
||||
if params.Scope != "" {
|
||||
data["scope"] = params.Scope
|
||||
}
|
||||
if params.TokenExpiresIn != 0 {
|
||||
data["token_expires_in"] = params.TokenExpiresIn
|
||||
}
|
||||
|
||||
for i := 0; i < maxCollisionRetry; i++ {
|
||||
code, err := nanoid.Generate(codeAlphabet, codeLength)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate OTP code: %w", err)
|
||||
}
|
||||
|
||||
key := s.storeKey(code)
|
||||
if s.store.Has(key) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.store.Set(key, data, ttl(params.ExpiresIn)); err != nil {
|
||||
return "", fmt.Errorf("failed to store OTP code: %w", err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to generate unique OTP code after %d attempts", maxCollisionRetry)
|
||||
}
|
||||
|
||||
func validateCreateParams(p *GenerateParams) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("params is required")
|
||||
}
|
||||
if p.UserID == "" && p.MemberID == "" {
|
||||
return fmt.Errorf("user_id or member_id is required")
|
||||
}
|
||||
if p.MemberID != "" && p.TeamID == "" {
|
||||
return fmt.Errorf("team_id is required when member_id is set")
|
||||
}
|
||||
if p.Redirect == "" {
|
||||
return fmt.Errorf("redirect is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
214
openapi/otp/handler.go
Normal file
214
openapi/otp/handler.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
package otp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/gou/session"
|
||||
"github.com/yaoapp/yao/openapi/oauth"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
"github.com/yaoapp/yao/openapi/user"
|
||||
"github.com/yaoapp/yao/openapi/utils"
|
||||
)
|
||||
|
||||
// OTPCreateRequest is the JSON body for POST /otp/create.
|
||||
type OTPCreateRequest struct {
|
||||
MemberID string `json:"member_id,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
Redirect string `json:"redirect" binding:"required"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
TokenExpiresIn int `json:"token_expires_in,omitempty"` // access_token lifetime override (seconds)
|
||||
Consume *bool `json:"consume,omitempty"` // revoke code after login; nil means default (true)
|
||||
}
|
||||
|
||||
// OTPLoginRequest is the JSON body for POST /otp/login.
|
||||
type OTPLoginRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
}
|
||||
|
||||
// Attach registers OTP HTTP routes on the given router group.
|
||||
// The create endpoint requires authentication; login is public.
|
||||
func Attach(group *gin.RouterGroup, auth types.OAuth) {
|
||||
group.POST("/create", auth.Guard, GinOTPCreate)
|
||||
group.POST("/login", GinOTPLogin)
|
||||
}
|
||||
|
||||
// GinOTPCreate handles POST /otp/create (protected).
|
||||
// It forces team_id from the caller's identity and validates that the
|
||||
// target member belongs to the same team.
|
||||
func GinOTPCreate(c *gin.Context) {
|
||||
authInfo := authorized.GetInfo(c)
|
||||
if authInfo == nil || authInfo.TeamID == "" {
|
||||
response.RespondWithError(c, http.StatusForbidden, &response.ErrorResponse{
|
||||
Code: "forbidden",
|
||||
ErrorDescription: "team context is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req OTPCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.UserID == "" && req.MemberID == "" {
|
||||
response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "user_id or member_id is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
teamID := authInfo.TeamID
|
||||
|
||||
// Validate that the target member/user belongs to the caller's team
|
||||
if err := validateTeamMembership(teamID, req.UserID, req.MemberID); err != nil {
|
||||
response.RespondWithError(c, http.StatusForbidden, &response.ErrorResponse{
|
||||
Code: "forbidden",
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
consume := true
|
||||
if req.Consume != nil {
|
||||
consume = *req.Consume
|
||||
}
|
||||
|
||||
code, err := OTP.Create(&GenerateParams{
|
||||
TeamID: teamID,
|
||||
MemberID: req.MemberID,
|
||||
UserID: req.UserID,
|
||||
ExpiresIn: req.ExpiresIn,
|
||||
Redirect: req.Redirect,
|
||||
Scope: req.Scope,
|
||||
TokenExpiresIn: req.TokenExpiresIn,
|
||||
Consume: consume,
|
||||
})
|
||||
if err != nil {
|
||||
response.RespondWithError(c, http.StatusInternalServerError, &response.ErrorResponse{
|
||||
Code: "server_error",
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, http.StatusOK, gin.H{"code": code})
|
||||
}
|
||||
|
||||
// GinOTPLogin handles POST /otp/login (public).
|
||||
// Smart login: checks existing session first, issues tokens only when needed.
|
||||
func GinOTPLogin(c *gin.Context) {
|
||||
var req OTPLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := OTP.Verify(req.Code)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{
|
||||
Code: "invalid_otp",
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var status string
|
||||
|
||||
// Check if the caller already has a valid session
|
||||
existingToken := oauth.OAuth.GetAccessToken(c)
|
||||
if existingToken != "" {
|
||||
if _, verifyErr := oauth.OAuth.VerifyToken(existingToken); verifyErr == nil {
|
||||
status = "already_logged_in"
|
||||
}
|
||||
}
|
||||
|
||||
// No valid session: perform OTP login and set cookies
|
||||
if status == "" {
|
||||
result, err := OTP.Login(req.Code, req.Locale)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{
|
||||
Code: "otp_login_failed",
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sid := utils.GetSessionID(c)
|
||||
if sid == "" {
|
||||
sid = session.ID()
|
||||
}
|
||||
|
||||
loginResp := &user.LoginResponse{
|
||||
UserID: result.UserID,
|
||||
Subject: result.Subject,
|
||||
AccessToken: result.AccessToken,
|
||||
IDToken: result.IDToken,
|
||||
RefreshToken: result.RefreshToken,
|
||||
ExpiresIn: result.ExpiresIn,
|
||||
RefreshTokenExpiresIn: result.RefreshTokenExpiresIn,
|
||||
TokenType: result.TokenType,
|
||||
Scope: result.Scope,
|
||||
Status: user.LoginStatusSuccess,
|
||||
}
|
||||
user.SendLoginCookies(c, loginResp, sid)
|
||||
status = "success"
|
||||
}
|
||||
|
||||
// Consume OTP code if configured
|
||||
if payload.Consume {
|
||||
_ = OTP.Revoke(req.Code)
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, http.StatusOK, gin.H{
|
||||
"status": status,
|
||||
"redirect": payload.Redirect,
|
||||
})
|
||||
}
|
||||
|
||||
// validateTeamMembership checks that the given user or member belongs to the specified team.
|
||||
func validateTeamMembership(teamID, userID, memberID string) error {
|
||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user provider: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if memberID != "" {
|
||||
member, err := userProvider.GetMemberByMemberID(ctx, memberID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("member not found: %s", memberID)
|
||||
}
|
||||
memberTeam := utils.ToString(member["team_id"])
|
||||
if memberTeam != teamID {
|
||||
return fmt.Errorf("member %s does not belong to team %s", memberID, teamID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if userID != "" {
|
||||
_, err := userProvider.GetMember(ctx, teamID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user %s is not a member of team %s", userID, teamID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("user_id or member_id is required")
|
||||
}
|
||||
95
openapi/otp/login.go
Normal file
95
openapi/otp/login.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package otp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/yao/openapi/oauth"
|
||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/openapi/user"
|
||||
"github.com/yaoapp/yao/openapi/utils"
|
||||
)
|
||||
|
||||
// Login verifies an OTP code, resolves the user identity, issues tokens,
|
||||
// and returns the result. It does NOT set HTTP cookies.
|
||||
func (s *Service) Login(code string, locale string) (*LoginResult, error) {
|
||||
payload, err := s.Verify(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userID, teamID, err := s.resolveIdentity(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve OTP identity: %w", err)
|
||||
}
|
||||
|
||||
loginCtx := &oauthtypes.LoginContext{
|
||||
Locale: locale,
|
||||
AuthSource: "otp",
|
||||
}
|
||||
|
||||
opts := &user.LoginOptions{
|
||||
SkipRefreshToken: true,
|
||||
TokenExpiresIn: payload.TokenExpiresIn,
|
||||
}
|
||||
if payload.Scope != "" {
|
||||
opts.Scopes = strings.Fields(payload.Scope)
|
||||
}
|
||||
|
||||
loginResp, err := user.LoginWithOptions(userID, teamID, loginCtx, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OTP login failed: %w", err)
|
||||
}
|
||||
|
||||
return &LoginResult{
|
||||
UserID: loginResp.UserID,
|
||||
Subject: loginResp.Subject,
|
||||
AccessToken: loginResp.AccessToken,
|
||||
IDToken: loginResp.IDToken,
|
||||
RefreshToken: loginResp.RefreshToken,
|
||||
ExpiresIn: loginResp.ExpiresIn,
|
||||
RefreshTokenExpiresIn: loginResp.RefreshTokenExpiresIn,
|
||||
TokenType: loginResp.TokenType,
|
||||
Scope: loginResp.Scope,
|
||||
Redirect: payload.Redirect,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveIdentity determines the user_id and team_id from the OTP payload.
|
||||
// When member_id is present, it resolves the user_id from the member record.
|
||||
func (s *Service) resolveIdentity(payload *Payload) (userID string, teamID string, err error) {
|
||||
teamID = payload.TeamID
|
||||
|
||||
if payload.UserID != "" {
|
||||
return payload.UserID, teamID, nil
|
||||
}
|
||||
|
||||
if payload.MemberID == "" {
|
||||
return "", "", fmt.Errorf("payload has neither user_id nor member_id")
|
||||
}
|
||||
|
||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get user provider: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
member, err := userProvider.GetMemberByMemberID(ctx, payload.MemberID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to resolve member %s: %w", payload.MemberID, err)
|
||||
}
|
||||
|
||||
userID = utils.ToString(member["user_id"])
|
||||
if userID == "" {
|
||||
return "", "", fmt.Errorf("member %s has no associated user_id", payload.MemberID)
|
||||
}
|
||||
|
||||
if teamID == "" {
|
||||
teamID = utils.ToString(member["team_id"])
|
||||
}
|
||||
|
||||
return userID, teamID, nil
|
||||
}
|
||||
81
openapi/otp/otp.go
Normal file
81
openapi/otp/otp.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package otp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/store"
|
||||
)
|
||||
|
||||
// OTP is the global OTP service instance, initialized by NewService.
|
||||
var OTP *Service
|
||||
|
||||
const (
|
||||
defaultExpiresIn = 24 * 60 * 60 // 24 hours in seconds
|
||||
storeKeyInfix = "oauth:otp:"
|
||||
codeAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
|
||||
codeLength = 12
|
||||
maxCollisionRetry = 5
|
||||
)
|
||||
|
||||
// Service manages OTP code lifecycle.
|
||||
type Service struct {
|
||||
store store.Store
|
||||
prefix string // key prefix, e.g. "yao_:"
|
||||
}
|
||||
|
||||
// Payload is the data stored alongside an OTP code.
|
||||
type Payload struct {
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
MemberID string `json:"member_id,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Redirect string `json:"redirect"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
TokenExpiresIn int `json:"token_expires_in,omitempty"`
|
||||
Consume bool `json:"consume"`
|
||||
}
|
||||
|
||||
// GenerateParams holds the input for creating an OTP code.
|
||||
type GenerateParams struct {
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
MemberID string `json:"member_id,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"` // seconds; 0 means default (24h)
|
||||
Redirect string `json:"redirect"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
TokenExpiresIn int `json:"token_expires_in,omitempty"` // access_token lifetime override (seconds); 0 means system default
|
||||
Consume bool `json:"consume"` // revoke code after login; default true
|
||||
}
|
||||
|
||||
// LoginResult wraps user.LoginResponse with the OTP redirect path.
|
||||
type LoginResult struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Redirect string `json:"redirect"`
|
||||
}
|
||||
|
||||
// NewService creates and registers a global OTP service.
|
||||
func NewService(s store.Store, prefix string) *Service {
|
||||
OTP = &Service{store: s, prefix: prefix}
|
||||
return OTP
|
||||
}
|
||||
|
||||
// storeKey builds a namespaced store key for the given OTP code.
|
||||
func (s *Service) storeKey(code string) string {
|
||||
return fmt.Sprintf("%s%s%s", s.prefix, storeKeyInfix, code)
|
||||
}
|
||||
|
||||
// ttl returns the effective TTL as a time.Duration.
|
||||
func ttl(expiresIn int) time.Duration {
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = defaultExpiresIn
|
||||
}
|
||||
return time.Duration(expiresIn) * time.Second
|
||||
}
|
||||
92
openapi/otp/process.go
Normal file
92
openapi/otp/process.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package otp
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/openapi/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
process.RegisterGroup("otp", map[string]process.Handler{
|
||||
"create": processCreate,
|
||||
"verify": processVerify,
|
||||
"login": processLogin,
|
||||
"revoke": processRevoke,
|
||||
})
|
||||
}
|
||||
|
||||
// processCreate handles otp.Create(params).
|
||||
// args[0]: map with team_id, member_id, user_id, expires_in, redirect, scope,
|
||||
//
|
||||
// token_expires_in, consume.
|
||||
func processCreate(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(1)
|
||||
|
||||
raw := p.ArgsMap(0)
|
||||
params := &GenerateParams{
|
||||
TeamID: utils.ToString(raw["team_id"]),
|
||||
MemberID: utils.ToString(raw["member_id"]),
|
||||
UserID: utils.ToString(raw["user_id"]),
|
||||
Redirect: utils.ToString(raw["redirect"]),
|
||||
Scope: utils.ToString(raw["scope"]),
|
||||
Consume: true,
|
||||
}
|
||||
if v, ok := raw["expires_in"]; ok {
|
||||
params.ExpiresIn = utils.ToInt(v)
|
||||
}
|
||||
if v, ok := raw["token_expires_in"]; ok {
|
||||
params.TokenExpiresIn = utils.ToInt(v)
|
||||
}
|
||||
if v, ok := raw["consume"]; ok {
|
||||
params.Consume = utils.ToBool(v)
|
||||
}
|
||||
|
||||
code, err := OTP.Create(params)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 400).Throw()
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// processVerify handles otp.Verify(code).
|
||||
// args[0]: code string.
|
||||
func processVerify(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(1)
|
||||
code := p.ArgsString(0)
|
||||
|
||||
payload, err := OTP.Verify(code)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 400).Throw()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// processLogin handles otp.Login(code, locale?).
|
||||
// args[0]: code string; args[1]: locale string (optional).
|
||||
func processLogin(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(1)
|
||||
code := p.ArgsString(0)
|
||||
|
||||
locale := ""
|
||||
if p.NumOfArgs() > 1 {
|
||||
locale = p.ArgsString(1)
|
||||
}
|
||||
|
||||
result, err := OTP.Login(code, locale)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 401).Throw()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// processRevoke handles otp.Revoke(code).
|
||||
// args[0]: code string.
|
||||
func processRevoke(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(1)
|
||||
code := p.ArgsString(0)
|
||||
|
||||
if err := OTP.Revoke(code); err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
12
openapi/otp/revoke.go
Normal file
12
openapi/otp/revoke.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package otp
|
||||
|
||||
// Revoke removes an OTP code from the store immediately.
|
||||
// It is silent when the code does not exist or has already expired.
|
||||
func (s *Service) Revoke(code string) error {
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
key := s.storeKey(code)
|
||||
_ = s.store.Del(key)
|
||||
return nil
|
||||
}
|
||||
76
openapi/otp/verify.go
Normal file
76
openapi/otp/verify.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package otp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Verify looks up an OTP code and returns its stored Payload.
|
||||
// It does NOT consume the code — the code remains valid within its TTL.
|
||||
func (s *Service) Verify(code string) (*Payload, error) {
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("code is required")
|
||||
}
|
||||
|
||||
key := s.storeKey(code)
|
||||
val, ok := s.store.Get(key)
|
||||
if !ok || val == nil {
|
||||
return nil, fmt.Errorf("invalid or expired OTP code")
|
||||
}
|
||||
|
||||
return coercePayload(val)
|
||||
}
|
||||
|
||||
// coercePayload converts a store value into a *Payload.
|
||||
// The store may return *Payload, map[string]interface{}, or raw JSON bytes.
|
||||
func coercePayload(val interface{}) (*Payload, error) {
|
||||
switch v := val.(type) {
|
||||
case *Payload:
|
||||
return v, nil
|
||||
|
||||
case Payload:
|
||||
return &v, nil
|
||||
|
||||
case map[string]interface{}:
|
||||
p := &Payload{Consume: true}
|
||||
if s, ok := v["team_id"].(string); ok {
|
||||
p.TeamID = s
|
||||
}
|
||||
if s, ok := v["member_id"].(string); ok {
|
||||
p.MemberID = s
|
||||
}
|
||||
if s, ok := v["user_id"].(string); ok {
|
||||
p.UserID = s
|
||||
}
|
||||
if s, ok := v["redirect"].(string); ok {
|
||||
p.Redirect = s
|
||||
}
|
||||
if s, ok := v["scope"].(string); ok {
|
||||
p.Scope = s
|
||||
}
|
||||
switch te := v["token_expires_in"].(type) {
|
||||
case float64:
|
||||
p.TokenExpiresIn = int(te)
|
||||
case int:
|
||||
p.TokenExpiresIn = te
|
||||
case int64:
|
||||
p.TokenExpiresIn = int(te)
|
||||
}
|
||||
if b, ok := v["consume"].(bool); ok {
|
||||
p.Consume = b
|
||||
}
|
||||
return p, nil
|
||||
|
||||
default:
|
||||
// JSON fallback for serialised store backends
|
||||
raw, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unexpected OTP payload type: %T", val)
|
||||
}
|
||||
p := &Payload{Consume: true}
|
||||
if err := json.Unmarshal(raw, p); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode OTP payload: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
777
openapi/tests/otp/otp_test.go
Normal file
777
openapi/tests/otp/otp_test.go
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
package otp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
"github.com/yaoapp/yao/openapi/oauth"
|
||||
"github.com/yaoapp/yao/openapi/otp"
|
||||
"github.com/yaoapp/yao/openapi/tests/testutils"
|
||||
)
|
||||
|
||||
type otpTestContext struct {
|
||||
UserID string
|
||||
TeamID string
|
||||
MemberID string
|
||||
Token string // access token with team context
|
||||
ClientID string
|
||||
Scope string
|
||||
}
|
||||
|
||||
// setupTestData creates a real user, team type, team, and member for OTP tests.
|
||||
// Returns an otpTestContext and a cleanup function.
|
||||
func setupTestData(t *testing.T, serverURL string) (*otpTestContext, func()) {
|
||||
t.Helper()
|
||||
provider := testutils.GetUserProvider(t)
|
||||
ctx := context.Background()
|
||||
|
||||
client := testutils.RegisterTestClient(t, "OTP Test Client", []string{"https://localhost/callback"})
|
||||
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
|
||||
// Step 1: Create a team type
|
||||
teamTypeID := fmt.Sprintf("otp_team_type_%d", time.Now().UnixNano())
|
||||
_, err := provider.CreateType(ctx, map[string]interface{}{
|
||||
"type_id": teamTypeID,
|
||||
"name": "OTP Test Team Type",
|
||||
"locale": "en-US",
|
||||
"description": "Team type for OTP tests",
|
||||
"is_active": true,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
require.NoError(t, err, "Failed to create team type")
|
||||
|
||||
// Step 2: Create a team with role_id (required for ACL)
|
||||
teamID := fmt.Sprintf("otp_test_team_%d", time.Now().UnixNano())
|
||||
_, err = provider.CreateTeam(ctx, map[string]interface{}{
|
||||
"team_id": teamID,
|
||||
"name": "OTP Test Team",
|
||||
"description": "Team for OTP integration tests",
|
||||
"owner_id": tokenInfo.UserID,
|
||||
"type_id": teamTypeID,
|
||||
"role_id": "system:root",
|
||||
"status": "active",
|
||||
"is_verified": true,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
require.NoError(t, err, "Failed to create test team")
|
||||
|
||||
// Step 3: Add user as team member with system:root role
|
||||
memberID, err := provider.CreateMember(ctx, map[string]interface{}{
|
||||
"team_id": teamID,
|
||||
"user_id": tokenInfo.UserID,
|
||||
"member_type": "user",
|
||||
"role_id": "system:root",
|
||||
"is_owner": true,
|
||||
"status": "active",
|
||||
"joined_at": time.Now(),
|
||||
"created_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
require.NoError(t, err, "Failed to create team member")
|
||||
|
||||
// Step 4: Get team details for extra claims
|
||||
team, err := provider.GetTeamByMember(ctx, teamID, tokenInfo.UserID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 5: Create access token with team context
|
||||
oauthService := oauth.OAuth
|
||||
require.NotNil(t, oauthService, "OAuth service not initialized")
|
||||
|
||||
subject, err := oauthService.Subject(client.ClientID, tokenInfo.UserID)
|
||||
require.NoError(t, err)
|
||||
|
||||
extraClaims := map[string]interface{}{
|
||||
"user_id": tokenInfo.UserID,
|
||||
"team_id": teamID,
|
||||
}
|
||||
if tenantID, ok := team["tenant_id"].(string); ok && tenantID != "" {
|
||||
extraClaims["tenant_id"] = tenantID
|
||||
}
|
||||
if ownerID, ok := team["owner_id"].(string); ok && ownerID != "" {
|
||||
extraClaims["owner_id"] = ownerID
|
||||
}
|
||||
if typeID, ok := team["type_id"].(string); ok && typeID != "" {
|
||||
extraClaims["type_id"] = typeID
|
||||
}
|
||||
|
||||
scope := "openid profile email system:root"
|
||||
accessToken, err := oauthService.MakeAccessToken(client.ClientID, scope, subject, 3600, extraClaims)
|
||||
require.NoError(t, err)
|
||||
|
||||
cleanup := func() {
|
||||
provider.DeleteTeam(ctx, teamID)
|
||||
provider.DeleteType(ctx, teamTypeID)
|
||||
testutils.CleanupTestClient(t, client.ClientID)
|
||||
}
|
||||
|
||||
return &otpTestContext{
|
||||
UserID: tokenInfo.UserID,
|
||||
TeamID: teamID,
|
||||
MemberID: memberID,
|
||||
Token: accessToken,
|
||||
ClientID: client.ClientID,
|
||||
Scope: scope,
|
||||
}, cleanup
|
||||
}
|
||||
|
||||
// ---------- POST /otp/login (public) ----------
|
||||
|
||||
func TestOTPLoginInvalidCode(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"code": "nonexistent_code"})
|
||||
resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Equal(t, "invalid_otp", result["error"])
|
||||
}
|
||||
|
||||
func TestOTPLoginMissingCode(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{})
|
||||
resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestOTPLoginInvalidJSON(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBufferString("not json"))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
// ---------- POST /otp/create (protected) ----------
|
||||
|
||||
func TestOTPCreateUnauthorized(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"user_id": "someone",
|
||||
"redirect": "/dashboard",
|
||||
})
|
||||
resp, err := http.Post(serverURL+baseURL+"/otp/create", "application/json", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestOTPCreateMissingRedirect(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
tc, cleanup := setupTestData(t, serverURL)
|
||||
defer cleanup()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"user_id": tc.UserID,
|
||||
})
|
||||
req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tc.Token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
rawBody, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(rawBody))
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestOTPCreateMissingUserAndMember(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
tc, cleanup := setupTestData(t, serverURL)
|
||||
defer cleanup()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"redirect": "/dashboard",
|
||||
})
|
||||
req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tc.Token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
rawBody, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(rawBody))
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
// ---------- Full flow: create -> login ----------
|
||||
|
||||
func TestOTPCreateAndLogin(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
tc, cleanup := setupTestData(t, serverURL)
|
||||
defer cleanup()
|
||||
|
||||
// Step 1: Create OTP code via API
|
||||
createBody, _ := json.Marshal(map[string]interface{}{
|
||||
"user_id": tc.UserID,
|
||||
"redirect": "/test/dashboard",
|
||||
})
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+tc.Token)
|
||||
|
||||
createResp, err := http.DefaultClient.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
|
||||
createBody2, _ := io.ReadAll(createResp.Body)
|
||||
t.Logf("Create response status: %d, body: %s", createResp.StatusCode, string(createBody2))
|
||||
require.Equal(t, http.StatusOK, createResp.StatusCode, "OTP create should succeed, body: %s", string(createBody2))
|
||||
|
||||
var createResult map[string]interface{}
|
||||
err = json.Unmarshal(createBody2, &createResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
code, ok := createResult["code"].(string)
|
||||
require.True(t, ok, "Response should contain a code string")
|
||||
assert.Len(t, code, 12, "OTP code should be 12 characters long")
|
||||
t.Logf("Created OTP code: %s", code)
|
||||
|
||||
// Step 2: Login with the OTP code (public endpoint)
|
||||
loginBody, _ := json.Marshal(map[string]string{
|
||||
"code": code,
|
||||
"locale": "en-US",
|
||||
})
|
||||
loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody))
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
|
||||
loginBody2, _ := io.ReadAll(loginResp.Body)
|
||||
t.Logf("Login response status: %d, body: %s", loginResp.StatusCode, string(loginBody2))
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode, "OTP login should succeed, body: %s", string(loginBody2))
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
err = json.Unmarshal(loginBody2, &loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "success", loginResult["status"])
|
||||
assert.Equal(t, "/test/dashboard", loginResult["redirect"])
|
||||
|
||||
// Step 3: Verify the code is consumed (default Consume=true)
|
||||
loginBody3, _ := json.Marshal(map[string]string{"code": code})
|
||||
loginResp3, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody3))
|
||||
require.NoError(t, err)
|
||||
defer loginResp3.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, loginResp3.StatusCode, "Code should be consumed after first login")
|
||||
}
|
||||
|
||||
func TestOTPCreateAndLoginWithMemberID(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
tc, cleanup := setupTestData(t, serverURL)
|
||||
defer cleanup()
|
||||
|
||||
// Create OTP with member_id instead of user_id
|
||||
createBody, _ := json.Marshal(map[string]interface{}{
|
||||
"team_id": tc.TeamID,
|
||||
"member_id": tc.MemberID,
|
||||
"redirect": "/member-login-test",
|
||||
})
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+tc.Token)
|
||||
|
||||
createResp, err := http.DefaultClient.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
|
||||
createRaw, _ := io.ReadAll(createResp.Body)
|
||||
t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw))
|
||||
require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw))
|
||||
|
||||
var createResult map[string]interface{}
|
||||
json.Unmarshal(createRaw, &createResult)
|
||||
code := createResult["code"].(string)
|
||||
|
||||
// Verify the stored payload has member_id
|
||||
payload, err := otp.OTP.Verify(code)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.MemberID, payload.MemberID)
|
||||
assert.Equal(t, tc.TeamID, payload.TeamID)
|
||||
assert.Equal(t, "", payload.UserID, "user_id should be empty when member_id is used")
|
||||
|
||||
// Login with the OTP code — should resolve member_id to user_id
|
||||
loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"})
|
||||
loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody))
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
|
||||
loginRaw, _ := io.ReadAll(loginResp.Body)
|
||||
t.Logf("Login response: %d, body: %s", loginResp.StatusCode, string(loginRaw))
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode, "OTP login with member_id should succeed, body: %s", string(loginRaw))
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.Unmarshal(loginRaw, &loginResult)
|
||||
assert.Equal(t, "success", loginResult["status"])
|
||||
assert.Equal(t, "/member-login-test", loginResult["redirect"])
|
||||
}
|
||||
|
||||
func TestOTPCreateWithConsumeDisabled(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
tc, cleanup := setupTestData(t, serverURL)
|
||||
defer cleanup()
|
||||
|
||||
consumeFalse := false
|
||||
createBody, _ := json.Marshal(map[string]interface{}{
|
||||
"user_id": tc.UserID,
|
||||
"redirect": "/reusable",
|
||||
"consume": &consumeFalse,
|
||||
})
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+tc.Token)
|
||||
|
||||
createResp, err := http.DefaultClient.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
|
||||
createRaw, _ := io.ReadAll(createResp.Body)
|
||||
t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw))
|
||||
require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw))
|
||||
|
||||
var createResult map[string]interface{}
|
||||
json.Unmarshal(createRaw, &createResult)
|
||||
code := createResult["code"].(string)
|
||||
t.Logf("Created reusable OTP code: %s", code)
|
||||
|
||||
// First login
|
||||
loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"})
|
||||
loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody))
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
// Second login should also work (Consume=false)
|
||||
loginBody2, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"})
|
||||
loginResp2, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody2))
|
||||
require.NoError(t, err)
|
||||
defer loginResp2.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, loginResp2.StatusCode, "Reusable OTP code should allow multiple logins")
|
||||
}
|
||||
|
||||
func TestOTPCreateWithTokenExpiresIn(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
tc, cleanup := setupTestData(t, serverURL)
|
||||
defer cleanup()
|
||||
|
||||
createBody, _ := json.Marshal(map[string]interface{}{
|
||||
"user_id": tc.UserID,
|
||||
"redirect": "/custom-ttl",
|
||||
"token_expires_in": 600,
|
||||
})
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+tc.Token)
|
||||
|
||||
createResp, err := http.DefaultClient.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
|
||||
createRaw, _ := io.ReadAll(createResp.Body)
|
||||
t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw))
|
||||
require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw))
|
||||
|
||||
var createResult map[string]interface{}
|
||||
json.Unmarshal(createRaw, &createResult)
|
||||
code := createResult["code"].(string)
|
||||
|
||||
payload, err := otp.OTP.Verify(code)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 600, payload.TokenExpiresIn)
|
||||
assert.Equal(t, "/custom-ttl", payload.Redirect)
|
||||
assert.True(t, payload.Consume)
|
||||
}
|
||||
|
||||
// ---------- Service-level tests (direct API) ----------
|
||||
|
||||
func TestOTPServiceCreateAndVerify(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
code, err := otp.OTP.Create(&otp.GenerateParams{
|
||||
UserID: "test_user_direct",
|
||||
TeamID: "test_team_direct",
|
||||
Redirect: "/direct-test",
|
||||
Consume: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, code, 12)
|
||||
|
||||
payload, err := otp.OTP.Verify(code)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test_user_direct", payload.UserID)
|
||||
assert.Equal(t, "test_team_direct", payload.TeamID)
|
||||
assert.Equal(t, "/direct-test", payload.Redirect)
|
||||
assert.True(t, payload.Consume)
|
||||
assert.Equal(t, 0, payload.TokenExpiresIn)
|
||||
}
|
||||
|
||||
func TestOTPServiceCreateAndVerifyWithMemberID(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
code, err := otp.OTP.Create(&otp.GenerateParams{
|
||||
TeamID: "team_member_test",
|
||||
MemberID: "member_12345",
|
||||
Redirect: "/member-redirect",
|
||||
Scope: "read:data",
|
||||
TokenExpiresIn: 900,
|
||||
Consume: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, code, 12)
|
||||
|
||||
payload, err := otp.OTP.Verify(code)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", payload.UserID)
|
||||
assert.Equal(t, "team_member_test", payload.TeamID)
|
||||
assert.Equal(t, "member_12345", payload.MemberID)
|
||||
assert.Equal(t, "/member-redirect", payload.Redirect)
|
||||
assert.Equal(t, "read:data", payload.Scope)
|
||||
assert.Equal(t, 900, payload.TokenExpiresIn)
|
||||
assert.False(t, payload.Consume)
|
||||
}
|
||||
|
||||
func TestOTPServiceCreateStoresMapPayload(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
code, err := otp.OTP.Create(&otp.GenerateParams{
|
||||
TeamID: "map_team",
|
||||
MemberID: "map_member",
|
||||
Redirect: "$dashboard/assistants",
|
||||
Scope: "openid profile",
|
||||
TokenExpiresIn: 600,
|
||||
Consume: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, err := otp.OTP.Verify(code)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "map_team", payload.TeamID)
|
||||
assert.Equal(t, "map_member", payload.MemberID)
|
||||
assert.Equal(t, "$dashboard/assistants", payload.Redirect)
|
||||
assert.Equal(t, "openid profile", payload.Scope)
|
||||
assert.Equal(t, 600, payload.TokenExpiresIn)
|
||||
assert.True(t, payload.Consume)
|
||||
}
|
||||
|
||||
func TestOTPServiceVerifyEmptyCode(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
_, err := otp.OTP.Verify("")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "code is required")
|
||||
}
|
||||
|
||||
func TestOTPServiceVerifyNonexistent(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
_, err := otp.OTP.Verify("doesnotexist1")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid or expired")
|
||||
}
|
||||
|
||||
func TestOTPServiceRevoke(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
code, err := otp.OTP.Create(&otp.GenerateParams{
|
||||
UserID: "revoke_user",
|
||||
Redirect: "/revoke-test",
|
||||
Consume: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = otp.OTP.Verify(code)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = otp.OTP.Revoke(code)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = otp.OTP.Verify(code)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid or expired")
|
||||
}
|
||||
|
||||
func TestOTPServiceRevokeEmpty(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
err := otp.OTP.Revoke("")
|
||||
assert.NoError(t, err, "Revoking empty code should be silent")
|
||||
}
|
||||
|
||||
func TestOTPServiceCreateValidation(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
params *otp.GenerateParams
|
||||
errMsg string
|
||||
}{
|
||||
{"nil params", nil, "params is required"},
|
||||
{"missing user and member", &otp.GenerateParams{Redirect: "/test"}, "user_id or member_id is required"},
|
||||
{"missing redirect", &otp.GenerateParams{UserID: "u1"}, "redirect is required"},
|
||||
{"member_id without team_id", &otp.GenerateParams{MemberID: "m1", Redirect: "/test"}, "team_id is required"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := otp.OTP.Create(tt.params)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOTPServiceCreateWithScope(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
code, err := otp.OTP.Create(&otp.GenerateParams{
|
||||
UserID: "scope_user",
|
||||
TeamID: "scope_team",
|
||||
Redirect: "/scoped",
|
||||
Scope: "read:data write:data",
|
||||
Consume: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, err := otp.OTP.Verify(code)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "read:data write:data", payload.Scope)
|
||||
assert.False(t, payload.Consume)
|
||||
}
|
||||
|
||||
func TestOTPServiceCreateWithCustomExpiry(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
code, err := otp.OTP.Create(&otp.GenerateParams{
|
||||
UserID: "expiry_user",
|
||||
Redirect: "/custom-expiry",
|
||||
ExpiresIn: 60,
|
||||
TokenExpiresIn: 300,
|
||||
Consume: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, err := otp.OTP.Verify(code)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 300, payload.TokenExpiresIn)
|
||||
}
|
||||
|
||||
// ---------- Cross-team validation ----------
|
||||
|
||||
func TestOTPCreateCrossTeamRejected(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
tc, cleanup := setupTestData(t, serverURL)
|
||||
defer cleanup()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"user_id": "user_not_in_team_" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
"redirect": "/cross-team",
|
||||
})
|
||||
req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tc.Token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "Should reject OTP creation for user not in team")
|
||||
}
|
||||
|
||||
// ---------- OTP Login sets cookies (no refresh token) ----------
|
||||
|
||||
func TestOTPLoginSetsAccessTokenCookieOnly(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
baseURL := ""
|
||||
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||
baseURL = openapi.Server.Config.BaseURL
|
||||
}
|
||||
|
||||
tc, cleanup := setupTestData(t, serverURL)
|
||||
defer cleanup()
|
||||
|
||||
createBody, _ := json.Marshal(map[string]interface{}{
|
||||
"user_id": tc.UserID,
|
||||
"redirect": "/cookie-test",
|
||||
})
|
||||
createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+tc.Token)
|
||||
|
||||
createResp, err := http.DefaultClient.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
|
||||
createRaw, _ := io.ReadAll(createResp.Body)
|
||||
t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw))
|
||||
require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw))
|
||||
|
||||
var createResult map[string]interface{}
|
||||
json.Unmarshal(createRaw, &createResult)
|
||||
code := createResult["code"].(string)
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"code": code})
|
||||
loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody))
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
cookies := loginResp.Cookies()
|
||||
hasAccessToken := false
|
||||
hasRefreshToken := false
|
||||
for _, c := range cookies {
|
||||
t.Logf("Cookie: %s", c.Name)
|
||||
if strings.HasSuffix(c.Name, "access_token") {
|
||||
hasAccessToken = true
|
||||
}
|
||||
if strings.HasSuffix(c.Name, "refresh_token") {
|
||||
hasRefreshToken = true
|
||||
}
|
||||
}
|
||||
assert.True(t, hasAccessToken, "OTP login should set access_token cookie")
|
||||
assert.False(t, hasRefreshToken, "OTP login should NOT set refresh_token cookie (SkipRefreshToken)")
|
||||
}
|
||||
|
||||
// ---------- Code uniqueness ----------
|
||||
|
||||
func TestOTPCodeUniqueness(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
codes := make(map[string]bool)
|
||||
for i := 0; i < 50; i++ {
|
||||
code, err := otp.OTP.Create(&otp.GenerateParams{
|
||||
UserID: fmt.Sprintf("unique_user_%d", i),
|
||||
Redirect: "/unique",
|
||||
Consume: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, codes[code], "Duplicate OTP code generated: %s", code)
|
||||
codes[code] = true
|
||||
}
|
||||
assert.Len(t, codes, 50, "All 50 codes should be unique")
|
||||
}
|
||||
|
|
@ -496,6 +496,115 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login
|
|||
return resp, nil
|
||||
}
|
||||
|
||||
// LoginWithOptions performs the same login flow as LoginByTeamID but allows
|
||||
// overriding scopes via opts. When opts.Scopes is non-nil, those scopes are
|
||||
// used instead of the user/client defaults.
|
||||
func LoginWithOptions(userid string, teamID string, loginCtx *LoginContext, opts *LoginOptions) (*LoginResponse, error) {
|
||||
if opts == nil {
|
||||
return LoginByTeamID(userid, teamID, loginCtx)
|
||||
}
|
||||
|
||||
hasOverrides := opts.Scopes != nil || opts.TokenExpiresIn > 0 || opts.SkipRefreshToken
|
||||
if !hasOverrides {
|
||||
return LoginByTeamID(userid, teamID, loginCtx)
|
||||
}
|
||||
|
||||
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
userData, err := userProvider.GetUserWithScopes(ctx, userid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve scopes: use opts.Scopes when provided, otherwise fall back to
|
||||
// the same default resolution as LoginByTeamID (user scopes or client config).
|
||||
scopes := opts.Scopes
|
||||
if scopes == nil {
|
||||
yaoClientConfig := GetYaoClientConfig()
|
||||
scopes = yaoClientConfig.Scopes
|
||||
if v, ok := userData["scopes"].([]string); ok {
|
||||
scopes = v
|
||||
}
|
||||
}
|
||||
|
||||
subject, err := oauth.OAuth.Subject(GetYaoClientConfig().ClientID, userid)
|
||||
if err != nil {
|
||||
log.Warn("Failed to store user fingerprint: %s", err.Error())
|
||||
}
|
||||
|
||||
if teamID == "" || teamID == "personal" {
|
||||
resp, err := issueTokens(ctx, &IssueTokensParams{
|
||||
UserID: userid,
|
||||
TeamID: "",
|
||||
Team: nil,
|
||||
Member: nil,
|
||||
User: userData,
|
||||
Subject: subject,
|
||||
Scopes: scopes,
|
||||
LoginCtx: loginCtx,
|
||||
TokenExpiresIn: opts.TokenExpiresIn,
|
||||
SkipRefreshToken: opts.SkipRefreshToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locale := ""
|
||||
if loginCtx != nil {
|
||||
locale = loginCtx.Locale
|
||||
}
|
||||
go prepareUserKBCollection(userid, "", locale)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
team, err := userProvider.GetTeamByMember(ctx, teamID, userid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("access denied: you are not a member of this team")
|
||||
}
|
||||
|
||||
member, err := userProvider.GetMember(ctx, teamID, userid)
|
||||
if err != nil {
|
||||
log.Warn("Failed to get member profile: %s", err.Error())
|
||||
member = nil
|
||||
}
|
||||
|
||||
if loginCtx != nil {
|
||||
err = userProvider.UpdateUserLastLogin(ctx, userid, loginCtx)
|
||||
if err != nil {
|
||||
log.Warn("Failed to update last login: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := issueTokens(ctx, &IssueTokensParams{
|
||||
UserID: userid,
|
||||
TeamID: teamID,
|
||||
Team: team,
|
||||
Member: member,
|
||||
User: userData,
|
||||
Subject: subject,
|
||||
Scopes: scopes,
|
||||
LoginCtx: loginCtx,
|
||||
TokenExpiresIn: opts.TokenExpiresIn,
|
||||
SkipRefreshToken: opts.SkipRefreshToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
locale := ""
|
||||
if loginCtx != nil {
|
||||
locale = loginCtx.Locale
|
||||
}
|
||||
go prepareUserKBCollection(userid, teamID, locale)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// issueTokens is the core function that issues all necessary tokens (ID token, access token, refresh token)
|
||||
func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse, error) {
|
||||
yaoClientConfig := GetYaoClientConfig()
|
||||
|
|
@ -571,6 +680,11 @@ func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse
|
|||
}
|
||||
}
|
||||
|
||||
// 4. Caller overrides (e.g. OTP login with custom token lifetime)
|
||||
if params.TokenExpiresIn > 0 {
|
||||
expiresIn = params.TokenExpiresIn
|
||||
}
|
||||
|
||||
// Prepare OIDC user info
|
||||
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(params.User)
|
||||
oidcUserInfo.Sub = params.Subject
|
||||
|
|
@ -717,15 +831,19 @@ func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse
|
|||
return nil, fmt.Errorf("failed to sign access token: %w", err)
|
||||
}
|
||||
|
||||
// Sign Refresh Token
|
||||
// Sign Refresh Token (skip for temporary sessions like OTP)
|
||||
var refreshToken string
|
||||
if len(extraClaims) > 0 {
|
||||
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn, extraClaims)
|
||||
if params.SkipRefreshToken {
|
||||
refreshTokenExpiresIn = 0
|
||||
} else {
|
||||
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign refresh token: %w", err)
|
||||
if len(extraClaims) > 0 {
|
||||
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn, extraClaims)
|
||||
} else {
|
||||
refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &LoginResponse{
|
||||
|
|
@ -901,17 +1019,17 @@ func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID st
|
|||
|
||||
// Normal Access Token
|
||||
accessToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.AccessToken)
|
||||
refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken)
|
||||
|
||||
// Calculate expiration times
|
||||
// access_token cookie lives as long as refresh_token so the browser keeps sending the
|
||||
// (JWT-expired) access token — the Guard can then use the refresh token to issue a new one.
|
||||
// The JWT's own `exp` claim handles the real expiration check on the server side.
|
||||
refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second)
|
||||
|
||||
// Send access token cookie (cookie lifetime = refresh token lifetime)
|
||||
response.SendAccessTokenCookieWithExpiry(c, accessToken, refreshExpires)
|
||||
|
||||
// Send refresh token cookie
|
||||
response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires)
|
||||
if loginResponse.RefreshToken != "" {
|
||||
refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken)
|
||||
// access_token cookie lives as long as refresh_token so the browser keeps sending the
|
||||
// (JWT-expired) access token — the Guard can then use the refresh token to issue a new one.
|
||||
refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second)
|
||||
response.SendAccessTokenCookieWithExpiry(c, accessToken, refreshExpires)
|
||||
response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires)
|
||||
} else {
|
||||
// No refresh token (e.g. OTP login): cookie expires with the access token
|
||||
accessExpires := time.Now().Add(time.Duration(loginResponse.ExpiresIn) * time.Second)
|
||||
response.SendAccessTokenCookieWithExpiry(c, accessToken, accessExpires)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -265,17 +265,26 @@ type LoginSuccessResponse struct {
|
|||
// LoginContext is an alias for the oauth types LoginContext
|
||||
type LoginContext = oauthtypes.LoginContext
|
||||
|
||||
// LoginOptions provides optional overrides for the login flow.
|
||||
type LoginOptions struct {
|
||||
Scopes []string // When non-nil, overrides the default scope resolution
|
||||
TokenExpiresIn int // When > 0, overrides default access_token expiration (seconds)
|
||||
SkipRefreshToken bool // When true, do not issue refresh_token (for OTP/temporary sessions)
|
||||
}
|
||||
|
||||
// IssueTokensParams represents parameters for issueTokens function
|
||||
type IssueTokensParams struct {
|
||||
UserID string // User ID
|
||||
TeamID string // Team ID (empty for personal account)
|
||||
Team map[string]interface{} // Team data (nil for personal account)
|
||||
Member map[string]interface{} // Member profile data (nil for personal account or if not available)
|
||||
User map[string]interface{} // User data
|
||||
Subject string // Token subject
|
||||
Scopes []string // Token scopes
|
||||
LoginCtx *LoginContext // Login context (IP, user agent, etc.)
|
||||
AuthSource string // Authentication source (password, google, github, etc.)
|
||||
UserID string // User ID
|
||||
TeamID string // Team ID (empty for personal account)
|
||||
Team map[string]interface{} // Team data (nil for personal account)
|
||||
Member map[string]interface{} // Member profile data (nil for personal account or if not available)
|
||||
User map[string]interface{} // User data
|
||||
Subject string // Token subject
|
||||
Scopes []string // Token scopes
|
||||
LoginCtx *LoginContext // Login context (IP, user agent, etc.)
|
||||
AuthSource string // Authentication source (password, google, github, etc.)
|
||||
TokenExpiresIn int // When > 0, overrides default access_token expiration (seconds)
|
||||
SkipRefreshToken bool // When true, skip refresh_token generation
|
||||
}
|
||||
|
||||
// ==== Entry Verification Types ====
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue