Organize backend into product executable and shared Go modules
This commit is contained in:
1 parent
981e84a4b4
commit
f0720ec66b
382 files changed
+1944
-6989
No files matched your search
@@ -0,0 +1,36 @@
|
||||
package middleware
|
||||
|
||||
type AuthStorageConfig struct {
|
||||
AccountsCollection string `yaml:"accounts_collection"`
|
||||
RefreshTokensCollection string `yaml:"refresh_tokens_collection"`
|
||||
ClientsCollection string `yaml:"clients_collection"`
|
||||
VerificationTokensCollection string `yaml:"verification_tokens_collection"`
|
||||
LoginAttemptsCollection string `yaml:"login_attempts_collection"`
|
||||
VerificationRateLimitsCollection string `yaml:"verification_rate_limits_collection"`
|
||||
}
|
||||
|
||||
func (c *AuthStorageConfig) WithDefaults() AuthStorageConfig {
|
||||
if c == nil {
|
||||
c = &AuthStorageConfig{}
|
||||
}
|
||||
res := *c
|
||||
if res.AccountsCollection == "" {
|
||||
res.AccountsCollection = "accounts"
|
||||
}
|
||||
if res.RefreshTokensCollection == "" {
|
||||
res.RefreshTokensCollection = "refresh_tokens"
|
||||
}
|
||||
if res.ClientsCollection == "" {
|
||||
res.ClientsCollection = "clients"
|
||||
}
|
||||
if res.VerificationTokensCollection == "" {
|
||||
res.VerificationTokensCollection = "verification_tokens"
|
||||
}
|
||||
if res.LoginAttemptsCollection == "" {
|
||||
res.LoginAttemptsCollection = "login_attempts"
|
||||
}
|
||||
if res.VerificationRateLimitsCollection == "" {
|
||||
res.VerificationRateLimitsCollection = "verification_rate_limits"
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCredentialedCORSRequiresExplicitOrigins(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config CORSSettings
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "explicit origin",
|
||||
config: CORSSettings{
|
||||
AllowCredentials: true,
|
||||
AllowedOrigins: []string{"https://app.sendico.io"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard",
|
||||
config: CORSSettings{
|
||||
AllowCredentials: true,
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing origin",
|
||||
config: CORSSettings{
|
||||
AllowCredentials: true,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-credentialed compatibility",
|
||||
config: CORSSettings{
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sharedlimit "git.sendico.io/tech/sxchnge/shared/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLoginAccountLimit = 5
|
||||
defaultLoginAccountWindow = 15 * time.Minute
|
||||
defaultLoginIPLimit = 50
|
||||
defaultLoginIPWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
type LoginRateLimitWindowConfig = sharedlimit.WindowConfig
|
||||
|
||||
type LoginRateLimitsConfig struct {
|
||||
PerAccount LoginRateLimitWindowConfig `yaml:"per_account"`
|
||||
PerIP LoginRateLimitWindowConfig `yaml:"per_ip"`
|
||||
}
|
||||
|
||||
type LoginRateLimitWindow = sharedlimit.Window
|
||||
|
||||
type LoginRateLimits struct {
|
||||
PerAccount LoginRateLimitWindow
|
||||
PerIP LoginRateLimitWindow
|
||||
}
|
||||
|
||||
func (c *Config) ResolveLoginRateLimits() (LoginRateLimits, error) {
|
||||
if c == nil || c.LoginRateLimits == nil {
|
||||
return LoginRateLimits{
|
||||
PerAccount: LoginRateLimitWindow{Limit: defaultLoginAccountLimit, Window: defaultLoginAccountWindow},
|
||||
PerIP: LoginRateLimitWindow{Limit: defaultLoginIPLimit, Window: defaultLoginIPWindow},
|
||||
}, nil
|
||||
}
|
||||
|
||||
account, err := sharedlimit.ResolveWindow(c.LoginRateLimits.PerAccount, "middleware.login_rate_limits.per_account")
|
||||
if err != nil {
|
||||
return LoginRateLimits{}, err
|
||||
}
|
||||
ip, err := sharedlimit.ResolveWindow(c.LoginRateLimits.PerIP, "middleware.login_rate_limits.per_ip")
|
||||
if err != nil {
|
||||
return LoginRateLimits{}, err
|
||||
}
|
||||
return LoginRateLimits{PerAccount: account, PerIP: ip}, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func TestResolveLoginRateLimitsUsesSecureDefaults(t *testing.T) {
|
||||
got, err := (&Config{}).ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve defaults: %v", err)
|
||||
}
|
||||
if got.PerAccount.Limit != 5 || got.PerAccount.Window != 15*time.Minute {
|
||||
t.Fatalf("unexpected account defaults: %+v", got.PerAccount)
|
||||
}
|
||||
if got.PerIP.Limit != 50 || got.PerIP.Window != 15*time.Minute {
|
||||
t.Fatalf("unexpected IP defaults: %+v", got.PerIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLoginRateLimitsRejectsUnsafeConfiguration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *LoginRateLimitsConfig
|
||||
}{
|
||||
{
|
||||
name: "zero account threshold",
|
||||
cfg: &LoginRateLimitsConfig{
|
||||
PerAccount: LoginRateLimitWindowConfig{Window: "15m"},
|
||||
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "15m"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid IP window",
|
||||
cfg: &LoginRateLimitsConfig{
|
||||
PerAccount: LoginRateLimitWindowConfig{Limit: 5, Window: "15m"},
|
||||
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "forever"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "excessive account window",
|
||||
cfg: &LoginRateLimitsConfig{
|
||||
PerAccount: LoginRateLimitWindowConfig{Limit: 5, Window: "25h"},
|
||||
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "15m"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := (&Config{LoginRateLimits: test.cfg}).ResolveLoginRateLimits()
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
type CORSSettings struct {
|
||||
MaxAge int `yaml:"max_age"`
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
AllowedMethods []string `yaml:"allowed_methods"`
|
||||
AllowedHeaders []string `yaml:"allowed_headers"`
|
||||
ExposedHeaders []string `yaml:"exposed_headers"`
|
||||
AllowCredentials bool `yaml:"allow_credentials"`
|
||||
}
|
||||
|
||||
func (c CORSSettings) Validate() error {
|
||||
if !c.AllowCredentials {
|
||||
return nil
|
||||
}
|
||||
if len(c.AllowedOrigins) == 0 {
|
||||
return merrors.InvalidArgument("credentialed CORS requires explicit allowed origins", "middleware.CORS.allowed_origins")
|
||||
}
|
||||
for _, origin := range c.AllowedOrigins {
|
||||
if strings.TrimSpace(origin) == "*" {
|
||||
return merrors.InvalidArgument("credentialed CORS does not allow wildcard origins", "middleware.CORS.allowed_origins")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Signature struct {
|
||||
PublicKey any
|
||||
PrivateKey []byte
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
type SignatureConfig struct {
|
||||
PublicKeyEnv string `yaml:"public_key_env,omitempty"`
|
||||
PrivateKeyEnv string `yaml:"secret_key_env"`
|
||||
Algorithm string `yaml:"algorithm"`
|
||||
}
|
||||
|
||||
type TokenExpiration struct {
|
||||
Account int `yaml:"account"`
|
||||
Refresh int `yaml:"refresh"`
|
||||
}
|
||||
|
||||
type TokenConfig struct {
|
||||
Expiration TokenExpiration `yaml:"expiration_hours"`
|
||||
Length int `yaml:"length"`
|
||||
}
|
||||
|
||||
const MaxAccessTokenTTLHours = 1
|
||||
|
||||
func (c TokenConfig) Validate() error {
|
||||
if c.Expiration.Account <= 0 || c.Expiration.Account > MaxAccessTokenTTLHours {
|
||||
return merrors.InvalidArgument("access token expiration must be between 1 and 1 hour", "middleware.token.expiration_hours.account")
|
||||
}
|
||||
if c.Expiration.Refresh <= c.Expiration.Account {
|
||||
return merrors.InvalidArgument("refresh token expiration must exceed access token expiration", "middleware.token.expiration_hours.refresh")
|
||||
}
|
||||
if c.Length < 32 {
|
||||
return merrors.InvalidArgument("refresh token length must be at least 32 bytes", "middleware.token.length")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PasswordChecks struct {
|
||||
Digit bool `yaml:"digit"`
|
||||
Upper bool `yaml:"upper"`
|
||||
Lower bool `yaml:"lower"`
|
||||
Special bool `yaml:"special"`
|
||||
MinLength int `yaml:"min_length"`
|
||||
}
|
||||
|
||||
type PasswordConfig struct {
|
||||
TokenLength int `yaml:"token_length"`
|
||||
Check PasswordChecks `yaml:"check"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DomainEnv string `yaml:"domain_env"`
|
||||
EndPointEnv string `yaml:"api_endpoint_env"`
|
||||
APIProtocolEnv string `yaml:"api_protocol_env"`
|
||||
Signature SignatureConfig `yaml:"signature"`
|
||||
CORS CORSSettings `yaml:"CORS"`
|
||||
Token TokenConfig `yaml:"token"`
|
||||
Password PasswordConfig `yaml:"password"`
|
||||
LoginRateLimits *LoginRateLimitsConfig `yaml:"login_rate_limits"`
|
||||
}
|
||||
|
||||
type MapClaims = map[string]any
|
||||
|
||||
func getKey(osEnv string) any {
|
||||
if len(osEnv) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []byte(os.Getenv(osEnv))
|
||||
}
|
||||
|
||||
func SignatureConf(conf *Config) Signature {
|
||||
return Signature{
|
||||
PrivateKey: []byte(os.Getenv(conf.Signature.PrivateKeyEnv)),
|
||||
PublicKey: getKey(conf.Signature.PublicKeyEnv),
|
||||
Algorithm: conf.Signature.Algorithm,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package middleware
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTokenConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config TokenConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "secure explicit TTLs",
|
||||
config: TokenConfig{
|
||||
Expiration: TokenExpiration{Account: 1, Refresh: 720},
|
||||
Length: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rejects legacy 24 hour access token",
|
||||
config: TokenConfig{
|
||||
Expiration: TokenExpiration{Account: 24, Refresh: 720},
|
||||
Length: 32,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "rejects missing access TTL",
|
||||
config: TokenConfig{
|
||||
Expiration: TokenExpiration{Refresh: 720},
|
||||
Length: 32,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "rejects weak refresh token",
|
||||
config: TokenConfig{
|
||||
Expiration: TokenExpiration{Account: 1, Refresh: 720},
|
||||
Length: 16,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user