Merge pull request 'api login method' (#581) from bff-580 into main
Some checks failed
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/frontend Pipeline failed

Reviewed-on: #581
This commit was merged in pull request #581.
This commit is contained in:
2026-02-28 09:08:21 +00:00
19 changed files with 455 additions and 24 deletions

View File

@@ -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"`
} }

View File

@@ -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{

View File

@@ -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)

View File

@@ -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,
} }

View File

@@ -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

View File

@@ -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),

View 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
}

View 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")
}
})
}

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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)

View File

@@ -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

View File

@@ -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),

View File

@@ -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)

View File

@@ -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:

View 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

View File

@@ -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

View File

@@ -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