api login method #581
@@ -5,4 +5,5 @@ import "github.com/tech/sendico/pkg/model"
|
|||||||
type Login struct {
|
type Login struct {
|
||||||
model.SessionIdentifier `json:",inline"`
|
model.SessionIdentifier `json:",inline"`
|
||||||
model.LoginData `json:"login"`
|
model.LoginData `json:"login"`
|
||||||
|
ClientSecret string `json:"clientSecret,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,18 @@ type AccountToken struct {
|
|||||||
Login string
|
Login string
|
||||||
Name string
|
Name string
|
||||||
Locale string
|
Locale string
|
||||||
|
ClientID string
|
||||||
Expiration time.Time
|
Expiration time.Time
|
||||||
Pending bool
|
Pending bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func createAccountToken(a *model.Account, expiration int) AccountToken {
|
func createAccountToken(a *model.Account, expiration int, clientID string) AccountToken {
|
||||||
return AccountToken{
|
return AccountToken{
|
||||||
AccountRef: *a.GetID(),
|
AccountRef: *a.GetID(),
|
||||||
Login: a.Login,
|
Login: a.Login,
|
||||||
Name: a.Name,
|
Name: a.Name,
|
||||||
Locale: a.Locale,
|
Locale: a.Locale,
|
||||||
|
ClientID: clientID,
|
||||||
Expiration: time.Now().Add(mduration.Param2Duration(expiration, time.Hour)),
|
Expiration: time.Now().Add(mduration.Param2Duration(expiration, time.Hour)),
|
||||||
Pending: false,
|
Pending: false,
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ const (
|
|||||||
paramNameName = "name"
|
paramNameName = "name"
|
||||||
paramNameLocale = "locale"
|
paramNameLocale = "locale"
|
||||||
paramNameLogin = "login"
|
paramNameLogin = "login"
|
||||||
|
paramNameClientID = "clientId"
|
||||||
paramNameExpiration = "exp"
|
paramNameExpiration = "exp"
|
||||||
paramNamePending = "pending"
|
paramNamePending = "pending"
|
||||||
)
|
)
|
||||||
@@ -68,6 +71,11 @@ func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) {
|
|||||||
if at.Locale, err = getTokenParam(claims, paramNameLocale); err != nil {
|
if at.Locale, err = getTokenParam(claims, paramNameLocale); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if clientID, ok := claims[paramNameClientID]; ok {
|
||||||
|
if clientIDText, ok := clientID.(string); ok {
|
||||||
|
at.ClientID = clientIDText
|
||||||
|
}
|
||||||
|
}
|
||||||
if pending, ok := claims[paramNamePending]; ok {
|
if pending, ok := claims[paramNamePending]; ok {
|
||||||
if pbool, ok := pending.(bool); ok {
|
if pbool, ok := pending.(bool); ok {
|
||||||
at.Pending = pbool
|
at.Pending = pbool
|
||||||
@@ -91,19 +99,24 @@ func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Account2Claims(a *model.Account, expiration int) middleware.MapClaims {
|
func Account2Claims(a *model.Account, expiration int) middleware.MapClaims {
|
||||||
t := createAccountToken(a, expiration)
|
return Account2ClaimsForClient(a, expiration, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Account2ClaimsForClient(a *model.Account, expiration int, clientID string) middleware.MapClaims {
|
||||||
|
t := createAccountToken(a, expiration, clientID)
|
||||||
return middleware.MapClaims{
|
return middleware.MapClaims{
|
||||||
paramNameID: t.AccountRef.Hex(),
|
paramNameID: t.AccountRef.Hex(),
|
||||||
paramNameLogin: t.Login,
|
paramNameLogin: t.Login,
|
||||||
paramNameName: t.Name,
|
paramNameName: t.Name,
|
||||||
paramNameLocale: t.Locale,
|
paramNameLocale: t.Locale,
|
||||||
|
paramNameClientID: t.ClientID,
|
||||||
paramNameExpiration: int64(t.Expiration.Unix()),
|
paramNameExpiration: int64(t.Expiration.Unix()),
|
||||||
paramNamePending: t.Pending,
|
paramNamePending: t.Pending,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PendingAccount2Claims(a *model.Account, expirationMinutes int) middleware.MapClaims {
|
func PendingAccount2Claims(a *model.Account, expirationMinutes int) middleware.MapClaims {
|
||||||
t := createAccountToken(a, expirationMinutes/60)
|
t := createAccountToken(a, expirationMinutes/60, "")
|
||||||
t.Expiration = time.Now().Add(time.Duration(expirationMinutes) * time.Minute)
|
t.Expiration = time.Now().Add(time.Duration(expirationMinutes) * time.Minute)
|
||||||
t.Pending = true
|
t.Pending = true
|
||||||
return middleware.MapClaims{
|
return middleware.MapClaims{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package routers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
api "github.com/tech/sendico/pkg/api/http"
|
api "github.com/tech/sendico/pkg/api/http"
|
||||||
@@ -13,11 +14,52 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||||
emodel "github.com/tech/sendico/server/interface/model"
|
emodel "github.com/tech/sendico/server/interface/model"
|
||||||
|
"github.com/tech/sendico/server/internal/api/routers/ipguard"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type tokenHandlerFunc = func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc
|
type tokenHandlerFunc = func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc
|
||||||
|
|
||||||
|
func (ar *AuthorizedRouter) validateClientPolicy(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
|
||||||
|
clientID := strings.TrimSpace(t.ClientID)
|
||||||
|
if clientID == "" {
|
||||||
|
// Legacy tokens without client_id remain valid until expiration.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
client, err := ar.rtdb.GetClient(r.Context(), clientID)
|
||||||
|
if errors.Is(err, merrors.ErrNoData) || client == nil {
|
||||||
|
ar.logger.Debug("Client not found for access token", zap.String("client_id", clientID))
|
||||||
|
return response.Unauthorized(ar.logger, ar.service, "client not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
ar.logger.Warn("Failed to resolve client for access token", zap.Error(err), zap.String("client_id", clientID))
|
||||||
|
return response.Internal(ar.logger, ar.service, err)
|
||||||
|
}
|
||||||
|
if client.IsRevoked {
|
||||||
|
return response.Unauthorized(ar.logger, ar.service, "client has been revoked")
|
||||||
|
}
|
||||||
|
if client.AccountRef != nil && *client.AccountRef != t.AccountRef {
|
||||||
|
return response.Unauthorized(ar.logger, ar.service, "client account mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIP := ipguard.ClientIP(r)
|
||||||
|
allowed, err := ipguard.Allowed(clientIP, client.AllowedCIDRs)
|
||||||
|
if err != nil {
|
||||||
|
ar.logger.Warn("Client IP policy contains invalid CIDR", zap.Error(err), zap.String("client_id", clientID))
|
||||||
|
return response.Forbidden(ar.logger, ar.service, "client_ip_policy_invalid", "client ip policy is invalid")
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
rawIP := ""
|
||||||
|
if clientIP != nil {
|
||||||
|
rawIP = clientIP.String()
|
||||||
|
}
|
||||||
|
ar.logger.Warn("Client IP policy denied authorized request", zap.String("client_id", clientID), zap.String("remote_ip", rawIP))
|
||||||
|
return response.Forbidden(ar.logger, ar.service, "ip_not_allowed", "request ip is not allowed for this client")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler tokenHandlerFunc) {
|
func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler tokenHandlerFunc) {
|
||||||
hndlr := func(r *http.Request) http.HandlerFunc {
|
hndlr := func(r *http.Request) http.HandlerFunc {
|
||||||
_, claims, err := jwtauth.FromContext(r.Context())
|
_, claims, err := jwtauth.FromContext(r.Context())
|
||||||
@@ -30,6 +72,9 @@ func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string,
|
|||||||
ar.logger.Debug("Failed to decode account token", zap.Error(err))
|
ar.logger.Debug("Failed to decode account token", zap.Error(err))
|
||||||
return response.BadRequest(ar.logger, ar.service, "credentials_unreadable", "faild to parse credentials")
|
return response.BadRequest(ar.logger, ar.service, "credentials_unreadable", "faild to parse credentials")
|
||||||
}
|
}
|
||||||
|
if h := ar.validateClientPolicy(r, t); h != nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
return handler(r, t)
|
return handler(r, t)
|
||||||
}
|
}
|
||||||
ar.imp.InstallHandler(service, endpoint, method, hndlr)
|
ar.imp.InstallHandler(service, endpoint, method, hndlr)
|
||||||
@@ -48,7 +93,7 @@ func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint strin
|
|||||||
}
|
}
|
||||||
return response.Internal(ar.logger, ar.service, err)
|
return response.Internal(ar.logger, ar.service, err)
|
||||||
}
|
}
|
||||||
accessToken, err := ar.imp.CreateAccessToken(&a)
|
accessToken, err := ar.imp.CreateAccessTokenForClient(&a, t.ClientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ar.logger.Warn("Failed to generate access token", zap.Error(err))
|
ar.logger.Warn("Failed to generate access token", zap.Error(err))
|
||||||
return response.Internal(ar.logger, ar.service, err)
|
return response.Internal(ar.logger, ar.service, err)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
"github.com/tech/sendico/pkg/auth"
|
"github.com/tech/sendico/pkg/auth"
|
||||||
"github.com/tech/sendico/pkg/db/account"
|
"github.com/tech/sendico/pkg/db/account"
|
||||||
|
"github.com/tech/sendico/pkg/db/refreshtokens"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
"github.com/tech/sendico/server/interface/middleware"
|
"github.com/tech/sendico/server/interface/middleware"
|
||||||
@@ -14,11 +15,12 @@ import (
|
|||||||
type AuthorizedRouter struct {
|
type AuthorizedRouter struct {
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
db account.DB
|
db account.DB
|
||||||
|
rtdb refreshtokens.DB
|
||||||
imp *re.HttpEndpointRouter
|
imp *re.HttpEndpointRouter
|
||||||
service mservice.Type
|
service mservice.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||||
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
|
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
|
||||||
router.Use(jwtauth.Verifier(ja))
|
router.Use(jwtauth.Verifier(ja))
|
||||||
router.Use(jwtauth.Authenticator(ja))
|
router.Use(jwtauth.Authenticator(ja))
|
||||||
@@ -26,6 +28,7 @@ func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db
|
|||||||
ar := AuthorizedRouter{
|
ar := AuthorizedRouter{
|
||||||
logger: l,
|
logger: l,
|
||||||
db: db,
|
db: db,
|
||||||
|
rtdb: rtdb,
|
||||||
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
|
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
|
||||||
service: mservice.Accounts,
|
service: mservice.Accounts,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func NewDispatcher(logger mlogger.Logger, router chi.Router, db account.DB, vdb
|
|||||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, r, &config.Token, &signature)
|
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, r, &config.Token, &signature)
|
||||||
})
|
})
|
||||||
router.Group(func(r chi.Router) {
|
router.Group(func(r chi.Router) {
|
||||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, enforcer, &config.Token, &signature)
|
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, enforcer, &config.Token, &signature)
|
||||||
})
|
})
|
||||||
|
|
||||||
return d
|
return d
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (er *HttpEndpointRouter) CreateAccessToken(user *model.Account) (sresponse.TokenData, error) {
|
func (er *HttpEndpointRouter) CreateAccessToken(user *model.Account) (sresponse.TokenData, error) {
|
||||||
|
return er.CreateAccessTokenForClient(user, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (er *HttpEndpointRouter) CreateAccessTokenForClient(user *model.Account, clientID string) (sresponse.TokenData, error) {
|
||||||
ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey)
|
ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey)
|
||||||
_, res, err := ja.Encode(emodel.Account2Claims(user, er.config.Expiration.Account))
|
_, res, err := ja.Encode(emodel.Account2ClaimsForClient(user, er.config.Expiration.Account, clientID))
|
||||||
token := sresponse.TokenData{
|
token := sresponse.TokenData{
|
||||||
Token: res,
|
Token: res,
|
||||||
Expiration: time.Now().Add(time.Duration(er.config.Expiration.Account) * time.Hour),
|
Expiration: time.Now().Add(time.Duration(er.config.Expiration.Account) * time.Hour),
|
||||||
|
|||||||
64
api/edge/bff/internal/api/routers/ipguard/ipguard.go
Normal file
64
api/edge/bff/internal/api/routers/ipguard/ipguard.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package ipguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientIP resolves caller IP from request remote address.
|
||||||
|
// The service relies on trusted proxy middleware to normalize RemoteAddr.
|
||||||
|
func ClientIP(r *http.Request) net.IP {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(r.RemoteAddr)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(raw); ip != nil {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return net.ParseIP(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCIDRs(raw []string) ([]*net.IPNet, error) {
|
||||||
|
blocks := make([]*net.IPNet, 0, len(raw))
|
||||||
|
for _, item := range raw {
|
||||||
|
clean := strings.TrimSpace(item)
|
||||||
|
if clean == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, block, err := net.ParseCIDR(clean)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
blocks = append(blocks, block)
|
||||||
|
}
|
||||||
|
return blocks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed reports whether clientIP is allowed by configured CIDRs.
|
||||||
|
// Empty CIDR list means unrestricted access.
|
||||||
|
func Allowed(clientIP net.IP, cidrs []string) (bool, error) {
|
||||||
|
blocks, err := parseCIDRs(cidrs)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(blocks) == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if clientIP == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
for _, block := range blocks {
|
||||||
|
if block.Contains(clientIP) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
86
api/edge/bff/internal/api/routers/ipguard/ipguard_test.go
Normal file
86
api/edge/bff/internal/api/routers/ipguard/ipguard_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package ipguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientIP(t *testing.T) {
|
||||||
|
t.Run("extracts host from remote addr", func(t *testing.T) {
|
||||||
|
req := &http.Request{RemoteAddr: "10.1.2.3:1234"}
|
||||||
|
ip := ClientIP(req)
|
||||||
|
if ip == nil || ip.String() != "10.1.2.3" {
|
||||||
|
t.Fatalf("unexpected ip: %v", ip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("supports plain ip", func(t *testing.T) {
|
||||||
|
req := &http.Request{RemoteAddr: "8.8.8.8"}
|
||||||
|
ip := ClientIP(req)
|
||||||
|
if ip == nil || ip.String() != "8.8.8.8" {
|
||||||
|
t.Fatalf("unexpected ip: %v", ip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid remote addr", func(t *testing.T) {
|
||||||
|
req := &http.Request{RemoteAddr: "invalid"}
|
||||||
|
if ip := ClientIP(req); ip != nil {
|
||||||
|
t.Fatalf("expected nil ip, got %v", ip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowed(t *testing.T) {
|
||||||
|
clientIP := net.ParseIP("10.1.2.3")
|
||||||
|
if clientIP == nil {
|
||||||
|
t.Fatal("failed to parse test ip")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("allows when cidr matches", func(t *testing.T) {
|
||||||
|
allowed, err := Allowed(clientIP, []string{"10.0.0.0/8"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Fatal("expected allowed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("denies when cidr does not match", func(t *testing.T) {
|
||||||
|
allowed, err := Allowed(clientIP, []string{"192.168.0.0/16"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
t.Fatal("expected denied")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allows when cidr list is empty", func(t *testing.T) {
|
||||||
|
allowed, err := Allowed(clientIP, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Fatal("expected allowed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid cidr fails", func(t *testing.T) {
|
||||||
|
_, err := Allowed(clientIP, []string{"not-a-cidr"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil client ip denied when cidrs configured", func(t *testing.T) {
|
||||||
|
allowed, err := Allowed(nil, []string{"10.0.0.0/8"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
t.Fatal("expected denied")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package routers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,36 +10,45 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
"github.com/tech/sendico/pkg/api/http/response"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
"github.com/tech/sendico/pkg/mutil/mask"
|
"github.com/tech/sendico/pkg/mutil/mask"
|
||||||
"github.com/tech/sendico/server/interface/api/srequest"
|
"github.com/tech/sendico/server/interface/api/srequest"
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||||
|
"github.com/tech/sendico/server/internal/api/routers/ipguard"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const pendingLoginTTLMinutes = 10
|
const pendingLoginTTLMinutes = 10
|
||||||
|
const apiLoginGrantType = "password"
|
||||||
|
const apiLoginClientAuthMethod = "client_secret_post"
|
||||||
|
|
||||||
func (pr *PublicRouter) logUserIn(ctx context.Context, _ *http.Request, req *srequest.Login) http.HandlerFunc {
|
func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.Login) (*model.Account, http.HandlerFunc) {
|
||||||
// Get the account database entry
|
// Get the account database entry
|
||||||
trimmedLogin := strings.TrimSpace(req.Login)
|
trimmedLogin := strings.TrimSpace(req.Login)
|
||||||
account, err := pr.db.GetByEmail(ctx, strings.ToLower(trimmedLogin))
|
account, err := pr.db.GetByEmail(ctx, strings.ToLower(trimmedLogin))
|
||||||
if errors.Is(err, merrors.ErrNoData) || (account == nil) {
|
if errors.Is(err, merrors.ErrNoData) || (account == nil) {
|
||||||
pr.logger.Debug("User not found while logging in", zap.Error(err), zap.String("login", req.Login))
|
pr.logger.Debug("User not found while logging in", zap.Error(err), zap.String("login", req.Login))
|
||||||
return response.Unauthorized(pr.logger, pr.service, "user not found")
|
return nil, response.Unauthorized(pr.logger, pr.service, "user not found")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pr.logger.Warn("Failed to query user with email", zap.Error(err), zap.String("login", req.Login))
|
pr.logger.Warn("Failed to query user with email", zap.Error(err), zap.String("login", req.Login))
|
||||||
return response.Internal(pr.logger, pr.service, err)
|
return nil, response.Internal(pr.logger, pr.service, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !account.IsActive() {
|
if !account.IsActive() {
|
||||||
return response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
|
return nil, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !account.MatchPassword(req.Password) {
|
if !account.MatchPassword(req.Password) {
|
||||||
return response.Unauthorized(pr.logger, pr.service, "password does not match")
|
return nil, response.Unauthorized(pr.logger, pr.service, "password does not match")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *PublicRouter) respondPendingLogin(account *model.Account) http.HandlerFunc {
|
||||||
pendingToken, err := pr.imp.CreatePendingToken(account, pendingLoginTTLMinutes)
|
pendingToken, err := pr.imp.CreatePendingToken(account, pendingLoginTTLMinutes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pr.logger.Warn("Failed to generate pending token", zap.Error(err))
|
pr.logger.Warn("Failed to generate pending token", zap.Error(err))
|
||||||
@@ -48,20 +58,144 @@ func (pr *PublicRouter) logUserIn(ctx context.Context, _ *http.Request, req *sre
|
|||||||
return sresponse.LoginPending(pr.logger, account, &pendingToken, mask.Email(account.Login))
|
return sresponse.LoginPending(pr.logger, account, &pendingToken, mask.Email(account.Login))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
|
func hasGrantType(grants []string, target string) bool {
|
||||||
// TODO: add rate check
|
for _, grant := range grants {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(grant), target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *PublicRouter) validateClientIPPolicy(r *http.Request, clientID string, client *model.Client) http.HandlerFunc {
|
||||||
|
if client == nil {
|
||||||
|
return response.Unauthorized(pr.logger, pr.service, "client not found")
|
||||||
|
}
|
||||||
|
clientIP := ipguard.ClientIP(r)
|
||||||
|
allowed, err := ipguard.Allowed(clientIP, client.AllowedCIDRs)
|
||||||
|
if err != nil {
|
||||||
|
pr.logger.Warn("Client IP policy contains invalid CIDR", zap.Error(err), zap.String("client_id", clientID))
|
||||||
|
return response.Forbidden(pr.logger, pr.service, "client_ip_policy_invalid", "client ip policy is invalid")
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
rawIP := ""
|
||||||
|
if clientIP != nil {
|
||||||
|
rawIP = clientIP.String()
|
||||||
|
}
|
||||||
|
pr.logger.Warn("Client IP policy denied request", zap.String("client_id", clientID), zap.String("remote_ip", rawIP))
|
||||||
|
return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *PublicRouter) validateAPIClient(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc {
|
||||||
|
client, err := pr.rtdb.GetClient(ctx, req.ClientID)
|
||||||
|
if errors.Is(err, merrors.ErrNoData) || client == nil {
|
||||||
|
pr.logger.Debug("API login rejected: client not found", zap.String("client_id", req.ClientID))
|
||||||
|
return response.Unauthorized(pr.logger, pr.service, "client not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
pr.logger.Warn("API login rejected: failed to load client", zap.Error(err), zap.String("client_id", req.ClientID))
|
||||||
|
return response.Internal(pr.logger, pr.service, err)
|
||||||
|
}
|
||||||
|
if client.IsRevoked {
|
||||||
|
return response.Forbidden(pr.logger, pr.service, "client_revoked", "client has been revoked")
|
||||||
|
}
|
||||||
|
if !hasGrantType(client.GrantTypes, apiLoginGrantType) {
|
||||||
|
return response.Forbidden(pr.logger, pr.service, "client_grant_not_allowed", "client does not allow password grant")
|
||||||
|
}
|
||||||
|
method := strings.ToLower(strings.TrimSpace(client.TokenEndpointAuthMethod))
|
||||||
|
if method == "" {
|
||||||
|
method = apiLoginClientAuthMethod
|
||||||
|
}
|
||||||
|
if method != apiLoginClientAuthMethod {
|
||||||
|
return response.Forbidden(pr.logger, pr.service, "client_auth_method_unsupported", "unsupported client auth method")
|
||||||
|
}
|
||||||
|
|
||||||
|
storedSecret := strings.TrimSpace(client.ClientSecret)
|
||||||
|
if storedSecret == "" {
|
||||||
|
return response.Forbidden(pr.logger, pr.service, "client_secret_missing", "client secret is not configured")
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(storedSecret), []byte(req.ClientSecret)) != 1 {
|
||||||
|
pr.logger.Debug("API login rejected: invalid client secret", zap.String("client_id", req.ClientID))
|
||||||
|
return response.Unauthorized(pr.logger, pr.service, "invalid client secret")
|
||||||
|
}
|
||||||
|
if client.AccountRef != nil {
|
||||||
|
accountRef := account.GetID()
|
||||||
|
if accountRef == nil || *client.AccountRef != *accountRef {
|
||||||
|
return response.Forbidden(pr.logger, pr.service, "client_account_mismatch", "client is bound to another account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h := pr.validateClientIPPolicy(r, req.ClientID, client); h != nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *PublicRouter) respondAPILogin(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc {
|
||||||
|
if req.ClientID == "" || req.DeviceID == "" {
|
||||||
|
return response.BadRequest(pr.logger, pr.service, "missing_session", "session identifier is required")
|
||||||
|
}
|
||||||
|
accessToken, err := pr.imp.CreateAccessTokenForClient(account, req.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
pr.logger.Warn("Failed to generate access token for API login", zap.Error(err))
|
||||||
|
return response.Internal(pr.logger, pr.service, err)
|
||||||
|
}
|
||||||
|
return pr.refreshAndRespondLogin(ctx, r, &req.SessionIdentifier, account, &accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLogin(r *http.Request, logger mlogger.Logger) (*srequest.Login, http.HandlerFunc) {
|
||||||
var req srequest.Login
|
var req srequest.Login
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
a.logger.Info("Failed to decode login request", zap.Error(err))
|
logger.Info("Failed to decode login request", zap.Error(err))
|
||||||
return response.BadPayload(a.logger, mservice.Accounts, err)
|
return nil, response.BadPayload(logger, mservice.Accounts, err)
|
||||||
}
|
}
|
||||||
req.Login = strings.TrimSpace(req.Login)
|
req.Login = strings.TrimSpace(req.Login)
|
||||||
req.Password = strings.TrimSpace(req.Password)
|
req.Password = strings.TrimSpace(req.Password)
|
||||||
|
req.ClientID = strings.TrimSpace(req.ClientID)
|
||||||
|
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
||||||
|
req.ClientSecret = strings.TrimSpace(req.ClientSecret)
|
||||||
|
|
||||||
if req.Login == "" {
|
if req.Login == "" {
|
||||||
return response.BadRequest(a.logger, mservice.Accounts, "email_missing", "login request has no user name")
|
return nil, response.BadRequest(logger, mservice.Accounts, "email_missing", "login request has no user name")
|
||||||
}
|
}
|
||||||
if req.Password == "" {
|
if req.Password == "" {
|
||||||
return response.BadRequest(a.logger, mservice.Accounts, "password_missing", "login request has no password")
|
return nil, response.BadRequest(logger, mservice.Accounts, "password_missing", "login request has no password")
|
||||||
}
|
}
|
||||||
return a.logUserIn(r.Context(), r, &req)
|
return &req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
|
||||||
|
// TODO: add rate check
|
||||||
|
req, h := decodeLogin(r, a.logger)
|
||||||
|
if h != nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
account, h := a.authenticateAccount(r.Context(), req)
|
||||||
|
if h != nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return a.respondPendingLogin(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *PublicRouter) apiLogin(r *http.Request) http.HandlerFunc {
|
||||||
|
req, h := decodeLogin(r, a.logger)
|
||||||
|
if h != nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
if req.ClientID == "" {
|
||||||
|
return response.BadRequest(a.logger, mservice.Accounts, "client_id_missing", "clientId is required")
|
||||||
|
}
|
||||||
|
if req.ClientSecret == "" {
|
||||||
|
return response.BadRequest(a.logger, mservice.Accounts, "client_secret_missing", "clientSecret is required")
|
||||||
|
}
|
||||||
|
account, h := a.authenticateAccount(r.Context(), req)
|
||||||
|
if h != nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
if h = a.validateAPIClient(r.Context(), r, req, account); h != nil {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return a.respondAPILogin(r.Context(), r, req, account)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package routers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
"github.com/tech/sendico/pkg/api/http/response"
|
||||||
@@ -21,6 +22,9 @@ func (pr *PublicRouter) refreshAccessToken(r *http.Request) http.HandlerFunc {
|
|||||||
|
|
||||||
account, token, err := pr.validateRefreshToken(r.Context(), r, &req)
|
account, token, err := pr.validateRefreshToken(r.Context(), r, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, errClientIPNotAllowed) {
|
||||||
|
return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client")
|
||||||
|
}
|
||||||
pr.logger.Warn("Failed to process access token refreshment request", zap.Error(err))
|
pr.logger.Warn("Failed to process access token refreshment request", zap.Error(err))
|
||||||
return response.Auto(pr.logger, pr.service, err)
|
return response.Auto(pr.logger, pr.service, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package routers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
"github.com/tech/sendico/pkg/api/http/response"
|
||||||
@@ -20,6 +21,9 @@ func (pr *PublicRouter) rotateRefreshToken(r *http.Request) http.HandlerFunc {
|
|||||||
|
|
||||||
account, token, err := pr.validateRefreshToken(r.Context(), r, &req)
|
account, token, err := pr.validateRefreshToken(r.Context(), r, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, errClientIPNotAllowed) {
|
||||||
|
return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client")
|
||||||
|
}
|
||||||
pr.logger.Warn("Failed to validate refresh token", zap.Error(err))
|
pr.logger.Warn("Failed to validate refresh token", zap.Error(err))
|
||||||
return response.Auto(pr.logger, pr.service, err)
|
return response.Auto(pr.logger, pr.service, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func NewRouter(logger mlogger.Logger, apiEndpoint string, db account.DB, vdb ver
|
|||||||
}
|
}
|
||||||
|
|
||||||
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
|
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
|
||||||
|
hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin)
|
||||||
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
|
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
|
||||||
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
|
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errClientIPNotAllowed = errors.New("client_ip_not_allowed")
|
||||||
|
|
||||||
func validateToken(token string, rt *model.RefreshToken) string {
|
func validateToken(token string, rt *model.RefreshToken) string {
|
||||||
if rt.AccountRef == nil {
|
if rt.AccountRef == nil {
|
||||||
return "missing account reference"
|
return "missing account reference"
|
||||||
@@ -31,7 +33,23 @@ func validateToken(token string, rt *model.RefreshToken) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pr *PublicRouter) validateRefreshToken(ctx context.Context, _ *http.Request, req *srequest.TokenRefreshRotate) (*model.Account, *sresponse.TokenData, error) {
|
func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Request, req *srequest.TokenRefreshRotate) (*model.Account, *sresponse.TokenData, error) {
|
||||||
|
client, err := pr.rtdb.GetClient(ctx, req.ClientID)
|
||||||
|
if errors.Is(err, merrors.ErrNoData) || client == nil {
|
||||||
|
pr.logger.Info("Refresh token rejected: client not found", zap.String("client_id", req.ClientID))
|
||||||
|
return nil, nil, merrors.Unauthorized("client not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
pr.logger.Warn("Failed to fetch client for refresh token validation", zap.Error(err), zap.String("client_id", req.ClientID))
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if client.IsRevoked {
|
||||||
|
return nil, nil, merrors.Unauthorized("client has been revoked")
|
||||||
|
}
|
||||||
|
if h := pr.validateClientIPPolicy(r, req.ClientID, client); h != nil {
|
||||||
|
return nil, nil, errClientIPNotAllowed
|
||||||
|
}
|
||||||
|
|
||||||
rt, err := pr.rtdb.GetByCRT(ctx, req)
|
rt, err := pr.rtdb.GetByCRT(ctx, req)
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
pr.logger.Info("Refresh token not found", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
|
pr.logger.Info("Refresh token not found", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
|
||||||
@@ -49,7 +67,7 @@ func (pr *PublicRouter) validateRefreshToken(ctx context.Context, _ *http.Reques
|
|||||||
return nil, nil, merrors.Unauthorized("user not found")
|
return nil, nil, merrors.Unauthorized("user not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := pr.imp.CreateAccessToken(&account)
|
accessToken, err := pr.imp.CreateAccessTokenForClient(&account, req.ClientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pr.logger.Warn("Failed to generate access token", zap.Error(err))
|
pr.logger.Warn("Failed to generate access token", zap.Error(err))
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
emodel "github.com/tech/sendico/server/interface/model"
|
emodel "github.com/tech/sendico/server/interface/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *VerificationAPI) createAccessToken(account *model.Account) (sresponse.TokenData, error) {
|
func (a *VerificationAPI) createAccessToken(account *model.Account, clientID string) (sresponse.TokenData, error) {
|
||||||
ja := jwtauth.New(a.signature.Algorithm, a.signature.PrivateKey, a.signature.PublicKey)
|
ja := jwtauth.New(a.signature.Algorithm, a.signature.PrivateKey, a.signature.PublicKey)
|
||||||
_, res, err := ja.Encode(emodel.Account2Claims(account, a.tokenConfig.Expiration.Account))
|
_, res, err := ja.Encode(emodel.Account2ClaimsForClient(account, a.tokenConfig.Expiration.Account, clientID))
|
||||||
token := sresponse.TokenData{
|
token := sresponse.TokenData{
|
||||||
Token: res,
|
Token: res,
|
||||||
Expiration: time.Now().Add(time.Duration(a.tokenConfig.Expiration.Account) * time.Hour),
|
Expiration: time.Now().Add(time.Duration(a.tokenConfig.Expiration.Account) * time.Hour),
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (a *VerificationAPI) verifyCode(r *http.Request, account *model.Account, to
|
|||||||
if req.SessionIdentifier.ClientID == "" || req.SessionIdentifier.DeviceID == "" {
|
if req.SessionIdentifier.ClientID == "" || req.SessionIdentifier.DeviceID == "" {
|
||||||
return response.BadRequest(a.logger, a.Name(), "missing_session", "session identifier is required")
|
return response.BadRequest(a.logger, a.Name(), "missing_session", "session identifier is required")
|
||||||
}
|
}
|
||||||
accessToken, err := a.createAccessToken(account)
|
accessToken, err := a.createAccessToken(account, req.SessionIdentifier.ClientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("Failed to generate access token", zap.Error(err))
|
a.logger.Warn("Failed to generate access token", zap.Error(err))
|
||||||
return response.Internal(a.logger, a.Name(), err)
|
return response.Internal(a.logger, a.Name(), err)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ tags:
|
|||||||
paths:
|
paths:
|
||||||
/accounts/login:
|
/accounts/login:
|
||||||
$ref: ./api/accounts/auth_login.yaml
|
$ref: ./api/accounts/auth_login.yaml
|
||||||
|
/accounts/login/api:
|
||||||
|
$ref: ./api/accounts/auth_login_api.yaml
|
||||||
/accounts/rotate:
|
/accounts/rotate:
|
||||||
$ref: ./api/accounts/auth_rotate.yaml
|
$ref: ./api/accounts/auth_rotate.yaml
|
||||||
/accounts/refresh:
|
/accounts/refresh:
|
||||||
|
|||||||
30
interface/api/accounts/auth_login_api.yaml
Normal file
30
interface/api/accounts/auth_login_api.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
post:
|
||||||
|
tags: [Accounts, Auth]
|
||||||
|
summary: API login using email/password and client credentials
|
||||||
|
description: |
|
||||||
|
Validates account credentials and eligible API client credentials, then returns final auth payload.
|
||||||
|
This endpoint bypasses login OTP/2FA and is intended only for approved API clients.
|
||||||
|
If the client definition includes `allowedCIDRs`, request source IP must match one of those CIDRs.
|
||||||
|
operationId: accountsApiLogin
|
||||||
|
requestBody:
|
||||||
|
$ref: ./bodies/auth.yaml#/components/requestBodies/ApiLoginBody
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Login successful
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: ../response/response.yaml#/components/schemas/BaseResponse
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: ./response/auth.yaml#/components/schemas/LoginData
|
||||||
|
'400':
|
||||||
|
$ref: ../response/operation.yaml#/components/responses/BadRequest
|
||||||
|
'401':
|
||||||
|
$ref: ../response/operation.yaml#/components/responses/Unauthorized
|
||||||
|
'403':
|
||||||
|
$ref: ../response/operation.yaml#/components/responses/Forbidden
|
||||||
|
'500':
|
||||||
|
$ref: ../response/operation.yaml#/components/responses/InternalServerError
|
||||||
@@ -6,6 +6,12 @@ components:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: ../request/auth.yaml#/components/schemas/LoginRequest
|
$ref: ../request/auth.yaml#/components/schemas/LoginRequest
|
||||||
|
ApiLoginBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: ../request/auth.yaml#/components/schemas/ApiLoginRequest
|
||||||
|
|
||||||
RefreshTokenBody:
|
RefreshTokenBody:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -8,10 +8,26 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
clientId:
|
clientId:
|
||||||
type: string
|
type: string
|
||||||
|
description: Client identifier bound to refresh token lifecycle and client policy checks.
|
||||||
deviceId:
|
deviceId:
|
||||||
type: string
|
type: string
|
||||||
login:
|
login:
|
||||||
$ref: ../../../models/auth/login_data.yaml#/components/schemas/LoginData
|
$ref: ../../../models/auth/login_data.yaml#/components/schemas/LoginData
|
||||||
|
|
||||||
|
ApiLoginRequest:
|
||||||
|
allOf:
|
||||||
|
- $ref: ./auth.yaml#/components/schemas/LoginRequest
|
||||||
|
- type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- clientId
|
||||||
|
- deviceId
|
||||||
|
- clientSecret
|
||||||
|
properties:
|
||||||
|
clientSecret:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
description: Client secret for `client_secret_post` authentication.
|
||||||
|
|
||||||
RefreshTokenRequest:
|
RefreshTokenRequest:
|
||||||
$ref: ../../../models/auth/client_refresh_token.yaml#/components/schemas/ClientRefreshToken
|
$ref: ../../../models/auth/client_refresh_token.yaml#/components/schemas/ClientRefreshToken
|
||||||
|
|||||||
Reference in New Issue
Block a user