203 lines
7.5 KiB
Go
203 lines
7.5 KiB
Go
package routers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/tech/sendico/pkg/api/http/response"
|
|
"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/mutil/mask"
|
|
"github.com/tech/sendico/server/interface/api/srequest"
|
|
"github.com/tech/sendico/server/interface/api/sresponse"
|
|
"github.com/tech/sendico/server/internal/api/routers/ipguard"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const pendingLoginTTLMinutes = 10
|
|
const apiLoginGrantType = "password"
|
|
const apiLoginClientAuthMethod = "client_secret_post"
|
|
|
|
func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.Login) (*model.Account, http.HandlerFunc) {
|
|
// Get the account database entry
|
|
trimmedLogin := strings.TrimSpace(req.Login)
|
|
account, err := pr.db.GetByEmail(ctx, strings.ToLower(trimmedLogin))
|
|
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))
|
|
return nil, response.Unauthorized(pr.logger, pr.service, "user not found")
|
|
}
|
|
if err != nil {
|
|
pr.logger.Warn("Failed to query user with email", zap.Error(err), zap.String("login", req.Login))
|
|
return nil, response.Internal(pr.logger, pr.service, err)
|
|
}
|
|
|
|
if !account.IsActive() {
|
|
return nil, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
|
|
}
|
|
|
|
if !account.MatchPassword(req.Password) {
|
|
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)
|
|
if err != nil {
|
|
pr.logger.Warn("Failed to generate pending token", zap.Error(err))
|
|
return response.Internal(pr.logger, pr.service, err)
|
|
}
|
|
|
|
return sresponse.LoginPending(pr.logger, account, &pendingToken, mask.Email(account.Login))
|
|
}
|
|
|
|
func hasGrantType(grants []string, target string) bool {
|
|
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 {
|
|
pr.logger.Info("Client not found, rejecting authorization", zap.String("client_id", clientID))
|
|
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
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
logger.Info("Failed to decode login request", zap.Error(err))
|
|
return nil, response.BadPayload(logger, mservice.Accounts, err)
|
|
}
|
|
req.Login = strings.TrimSpace(req.Login)
|
|
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 == "" {
|
|
return nil, response.BadRequest(logger, mservice.Accounts, "email_missing", "login request has no user name")
|
|
}
|
|
if req.Password == "" {
|
|
return nil, response.BadRequest(logger, mservice.Accounts, "password_missing", "login request has no password")
|
|
}
|
|
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)
|
|
}
|