mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-09-25 12:23:33 +08:00
Merge branch 'main' into feat/k8s_gateways
Reapply the Gateway API support on top of the KubernetesService rework from main, which moved the service to ding-managed watchers and a Lookup based LabelProvider, and started requiring an app to match a host the resource actually routes. Ingresses declare their hosts in spec.rules[].host while HTTPRoutes and GRPCRoutes use spec.hostnames, so host extraction is now dispatched per resource kind. Route hostnames may carry the Gateway API wildcard label, which is matched as a suffix, and routes without hostnames are skipped since the hosts of the gateway listeners they attach to cannot be resolved from the route alone. The cache key gains the resource kind because an Ingress and an HTTPRoute may share a name within a namespace, and the catch-all path warning is extended to HTTPRoute path matches. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
// For LDAP and OAuth groups and IP allow/deny, we default to allow even with a deny policy.
|
||||
// This is because we can't force the user to use groups in LDAP and OAuth if they would like to use
|
||||
// a deny policy. As for IP checks, we can't reliably get the client IP (most of Tinyauth instances are
|
||||
// behind a Docker bridge network) so to make it easier for users to use a deny policy without
|
||||
// issues with IPs we allow by default.
|
||||
|
||||
type RuleName string
|
||||
|
||||
const (
|
||||
RuleUserAllowed RuleName = "rule-user-allowed"
|
||||
RuleOAuthGroup RuleName = "rule-oauth-group"
|
||||
RuleLDAPGroup RuleName = "rule-ldap-group"
|
||||
RuleAuthEnabled RuleName = "rule-auth-enabled"
|
||||
RuleIPAllowed RuleName = "rule-ip-allowed"
|
||||
RuleIPBypassed RuleName = "rule-ip-bypassed"
|
||||
)
|
||||
|
||||
type UserAllowedRule struct {
|
||||
Log *logger.Logger
|
||||
}
|
||||
|
||||
func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if ctx.UserContext == nil {
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if ctx.ACLs == nil {
|
||||
return EffectAbstain
|
||||
}
|
||||
|
||||
if ctx.UserContext.Provider == model.ProviderOAuth {
|
||||
rule.Log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
|
||||
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Whitelist, ctx.UserContext.OAuth.Email)
|
||||
if err != nil {
|
||||
if errors.Is(err, utils.ErrFilterEmpty) {
|
||||
rule.Log.App.Debug().Msg("OAuth whitelist is empty, abstaining")
|
||||
return EffectAbstain
|
||||
}
|
||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.OAuth.Email).Msg("Invalid entry in OAuth whitelist")
|
||||
return EffectDeny
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Debug().Str("email", ctx.UserContext.OAuth.Email).Msg("User is in OAuth whitelist, allowing access")
|
||||
return EffectAllow
|
||||
}
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if ctx.ACLs.Users.Block != "" {
|
||||
rule.Log.App.Debug().Msg("Checking users block list")
|
||||
match, err := utils.CheckFilter(ctx.ACLs.Users.Block, ctx.UserContext.GetUsername())
|
||||
if err != nil {
|
||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users block list")
|
||||
return EffectDeny
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is in users block list, denying access")
|
||||
return EffectDeny
|
||||
}
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
rule.Log.App.Debug().Msg("Checking users allow list")
|
||||
|
||||
match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername())
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, utils.ErrFilterEmpty) {
|
||||
return EffectAbstain
|
||||
}
|
||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list")
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if match {
|
||||
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is in users allow list, allowing access")
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is not in users allow list, denying access")
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
type OAuthGroupRule struct {
|
||||
Log *logger.Logger
|
||||
}
|
||||
|
||||
func (rule *OAuthGroupRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if ctx.UserContext == nil {
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if ctx.ACLs == nil {
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
if !ctx.UserContext.IsOAuth() {
|
||||
rule.Log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
if len(ctx.ACLs.OAuth.Groups) == 0 {
|
||||
rule.Log.App.Debug().Msg("No OAuth groups specified in ACLs, allowing access")
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
if _, ok := model.OverrideProviders[ctx.UserContext.OAuth.ID]; ok {
|
||||
rule.Log.App.Debug().Str("provider", ctx.UserContext.OAuth.ID).Msg("Provider override detected, skipping group check")
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
for _, group := range ctx.UserContext.OAuth.Groups {
|
||||
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Groups, strings.TrimSpace(group))
|
||||
if err != nil {
|
||||
rule.Log.App.Warn().Err(err).Str("item", group).Msg("Invalid entry in OAuth groups ACL")
|
||||
return EffectDeny
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Trace().Str("group", group).Str("required", ctx.ACLs.OAuth.Groups).Msg("User group matched, allowing access")
|
||||
return EffectAllow
|
||||
}
|
||||
}
|
||||
|
||||
rule.Log.App.Debug().Msg("No groups matched")
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
type LDAPGroupRule struct {
|
||||
Log *logger.Logger
|
||||
}
|
||||
|
||||
func (rule *LDAPGroupRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if ctx.UserContext == nil {
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if ctx.ACLs == nil {
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
if !ctx.UserContext.IsLDAP() {
|
||||
rule.Log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
if len(ctx.ACLs.LDAP.Groups) == 0 {
|
||||
rule.Log.App.Debug().Msg("No LDAP groups specified in ACLs, allowing access")
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
for _, group := range ctx.UserContext.LDAP.Groups {
|
||||
match, err := utils.CheckFilter(ctx.ACLs.LDAP.Groups, strings.TrimSpace(group))
|
||||
if err != nil {
|
||||
rule.Log.App.Warn().Err(err).Str("item", group).Msg("Invalid entry in LDAP groups ACL")
|
||||
return EffectDeny
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Trace().Str("group", group).Str("required", ctx.ACLs.LDAP.Groups).Msg("User group matched, allowing access")
|
||||
return EffectAllow
|
||||
}
|
||||
}
|
||||
|
||||
rule.Log.App.Debug().Msg("No groups matched")
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
type AuthEnabledRule struct {
|
||||
Log *logger.Logger
|
||||
}
|
||||
|
||||
func (rule *AuthEnabledRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if ctx.ACLs == nil {
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if ctx.ACLs.Path.Block != "" {
|
||||
regex, err := regexp.Compile(ctx.ACLs.Path.Block)
|
||||
|
||||
if err != nil {
|
||||
rule.Log.App.Error().Err(err).Msg("Failed to compile block regex")
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if !regex.MatchString(ctx.Path) {
|
||||
return EffectAllow
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.ACLs.Path.Allow != "" {
|
||||
regex, err := regexp.Compile(ctx.ACLs.Path.Allow)
|
||||
|
||||
if err != nil {
|
||||
rule.Log.App.Error().Err(err).Msg("Failed to compile allow regex")
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if regex.MatchString(ctx.Path) {
|
||||
return EffectAllow
|
||||
}
|
||||
}
|
||||
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
type IPAllowedRule struct {
|
||||
Log *logger.Logger
|
||||
Config model.Config
|
||||
}
|
||||
|
||||
func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if !ctx.TrustedProxiesConfigured {
|
||||
return EffectAllow // We can't block the proxy
|
||||
}
|
||||
|
||||
// merge global and per-app block/allow lists
|
||||
blockedIps := append([]string{}, rule.Config.Auth.IP.Block...)
|
||||
allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...)
|
||||
|
||||
if ctx.ACLs != nil {
|
||||
blockedIps = append(blockedIps, ctx.ACLs.IP.Block...)
|
||||
allowedIPs = append(allowedIPs, ctx.ACLs.IP.Allow...)
|
||||
}
|
||||
|
||||
for _, blocked := range blockedIps {
|
||||
match, err := utils.CheckIPFilter(blocked, ctx.IP.String())
|
||||
if err != nil {
|
||||
rule.Log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
||||
continue
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Str("item", blocked).Msg("IP is in block list, denying access")
|
||||
return EffectDeny
|
||||
}
|
||||
}
|
||||
|
||||
for _, allowed := range allowedIPs {
|
||||
match, err := utils.CheckIPFilter(allowed, ctx.IP.String())
|
||||
if err != nil {
|
||||
rule.Log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
||||
continue
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Str("item", allowed).Msg("IP is in allow list, allowing access")
|
||||
return EffectAllow
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowedIPs) > 0 {
|
||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Msg("IP not in allow list, denying access")
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Msg("IP not in block or allow list, allowing access")
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
type IPBypassedRule struct {
|
||||
Log *logger.Logger
|
||||
Config model.Config
|
||||
}
|
||||
|
||||
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if !ctx.TrustedProxiesConfigured {
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
// merge global and per-app bypass lists
|
||||
bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...)
|
||||
if ctx.ACLs != nil {
|
||||
bypassList = append(bypassList, ctx.ACLs.IP.Bypass...)
|
||||
}
|
||||
|
||||
for _, bypassed := range bypassList {
|
||||
match, err := utils.CheckIPFilter(bypassed, ctx.IP.String())
|
||||
if err != nil {
|
||||
rule.Log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
||||
continue
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
|
||||
return EffectAllow
|
||||
}
|
||||
}
|
||||
|
||||
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Msg("IP not in bypass list, proceeding with authentication")
|
||||
return EffectDeny
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func TestUserAllowedRule(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
rule := &UserAllowedRule{Log: log}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *ACLContext
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "denies when user context is nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "alice"},
|
||||
},
|
||||
UserContext: nil,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "abstains when ACLs are nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
},
|
||||
{
|
||||
name: "abstains when filter is empty",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: ""},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
},
|
||||
{
|
||||
name: "allows OAuth user when email matches whitelist",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "allowed@example.com"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
BaseContext: model.BaseContext{
|
||||
Username: "different-username",
|
||||
Email: "allowed@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies OAuth user when email does not match whitelist",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "allowed@example.com"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
BaseContext: model.BaseContext{Email: "denied@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies for OAuth user when whitelist filter is invalid",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "/[/"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
BaseContext: model.BaseContext{Email: "allowed@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies local user when username matches block list",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Users: model.AppUsers{Block: "alice,bob"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows local user when username does not match block list",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Users: model.AppUsers{Block: "alice,bob"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "charlie"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies when block list filter is invalid",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Users: model.AppUsers{Block: "/[/"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "abstains when allow list is empty",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Users: model.AppUsers{Allow: ""},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
},
|
||||
{
|
||||
name: "allows local user when username matches allow list",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Users: model.AppUsers{Allow: "alice,bob"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies local user when username does not match allow list",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Users: model.AppUsers{Allow: "alice,bob"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "charlie"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies when allow list filter is invalid",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Users: model.AppUsers{Allow: "/[/"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthGroupRule(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
rule := &OAuthGroupRule{Log: log}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *ACLContext
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "denies when user context is nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "alice"},
|
||||
},
|
||||
UserContext: nil,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when ACLs are nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when user is not OAuth",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Groups: "admins"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when group filter is empty",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Groups: ""},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when provider is an override provider regardless of groups",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Groups: "admins"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
ID: "google",
|
||||
Groups: []string{"unrelated"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows OAuth user when a group matches",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Groups: "admins,users"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
ID: "custom",
|
||||
Groups: []string{"users"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies OAuth user when no group matches",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Groups: "admins"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
ID: "custom",
|
||||
Groups: []string{"users", "guests"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies OAuth user when user has no groups",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Groups: "admins"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
ID: "custom",
|
||||
Groups: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies when groups filter is invalid",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Groups: "/[/"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
ID: "custom",
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLDAPGroupRule(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
rule := &LDAPGroupRule{Log: log}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *ACLContext
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "denies when user context is nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "alice"},
|
||||
},
|
||||
UserContext: nil,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when acls are nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when user is not LDAP",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
LDAP: model.AppLDAP{Groups: "admins"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when group filter is empty",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
LDAP: model.AppLDAP{Groups: ""},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLDAP,
|
||||
LDAP: &model.LDAPContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows LDAP user when a group matches",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
LDAP: model.AppLDAP{Groups: "admins,users"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLDAP,
|
||||
LDAP: &model.LDAPContext{
|
||||
Groups: []string{"users"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies LDAP user when no group matches",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
LDAP: model.AppLDAP{Groups: "admins"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLDAP,
|
||||
LDAP: &model.LDAPContext{
|
||||
Groups: []string{"users", "guests"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies LDAP user when user has no groups",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
LDAP: model.AppLDAP{Groups: "admins"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLDAP,
|
||||
LDAP: &model.LDAPContext{
|
||||
Groups: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies when groups filter is invalid",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
LDAP: model.AppLDAP{Groups: "/[/"},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLDAP,
|
||||
LDAP: &model.LDAPContext{
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthEnabledRule(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
rule := &AuthEnabledRule{Log: log}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *ACLContext
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "deny when ACLs are nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
Path: "/anything",
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when path does not match block regex",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Path: model.AppPath{Block: "^/admin"},
|
||||
},
|
||||
Path: "/public",
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies when path matches block regex and no allow regex",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Path: model.AppPath{Block: "^/admin"},
|
||||
},
|
||||
Path: "/admin/users",
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when path matches allow regex",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Path: model.AppPath{Allow: "^/public"},
|
||||
},
|
||||
Path: "/public/index",
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies when path does not match allow regex",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Path: model.AppPath{Allow: "^/public"},
|
||||
},
|
||||
Path: "/private",
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when blocked path is also explicitly allowed",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Path: model.AppPath{
|
||||
Block: "^/admin",
|
||||
Allow: "^/admin/public",
|
||||
},
|
||||
},
|
||||
Path: "/admin/public/page",
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies when block regex fails to compile",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Path: model.AppPath{Block: "[invalid"},
|
||||
},
|
||||
Path: "/anything",
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies when allow regex fails to compile",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Path: model.AppPath{Allow: "[invalid"},
|
||||
},
|
||||
Path: "/anything",
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies when no path rules are configured",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
Path: "/anything",
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowedRule(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config model.Config
|
||||
ctx *ACLContext
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "when trusted proxies are not configured, IP is allowed",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when ACLs are nil and no global lists configured",
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies when IP matches app block list",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Block: []string{"10.0.0.1"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies when IP matches global block list",
|
||||
config: model.Config{
|
||||
Auth: model.AuthConfig{
|
||||
IP: model.IPConfig{Block: []string{"10.0.0.0/24"}},
|
||||
},
|
||||
},
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when IP matches app allow list",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("192.168.1.10"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when IP matches global allow list",
|
||||
config: model.Config{
|
||||
Auth: model.AuthConfig{
|
||||
IP: model.IPConfig{Allow: []string{"192.168.1.10"}},
|
||||
},
|
||||
},
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("192.168.1.10"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies when allow list is set and IP does not match",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when no block or allow lists are configured",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "block list takes precedence over allow list",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{
|
||||
Block: []string{"10.0.0.1"},
|
||||
Allow: []string{"10.0.0.1"},
|
||||
},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "skips invalid block entries and continues evaluation",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{
|
||||
Block: []string{"not-an-ip"},
|
||||
Allow: []string{"10.0.0.1"},
|
||||
},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rule := &IPAllowedRule{Log: log, Config: tt.config}
|
||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPBypassedRule(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
defaultIPBR := &IPBypassedRule{Log: log}
|
||||
globBypassIPBR := &IPBypassedRule{
|
||||
Log: log,
|
||||
Config: model.Config{Auth: model.AuthConfig{IP: model.IPConfig{Bypass: []string{"10.0.0.0/24"}}}},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rule *IPBypassedRule
|
||||
ctx *ACLContext
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "when trusted proxies are not configured, IP is not bypassed",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: false,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "deny when ACLs are nil and no global bypass",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when ACLs are nil but IP matches global bypass",
|
||||
rule: globBypassIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies when ACLs are nil and IP does not match global bypass",
|
||||
rule: globBypassIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("192.168.1.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when IP matches per-app bypass but not global bypass",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when IP matches global bypass but not per-app bypass",
|
||||
rule: globBypassIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when IP matches bypass list",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies when IP does not match bypass list",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("192.168.1.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies when bypass list is empty",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "skips invalid bypass entries and allows on later match",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, tt.rule.Evaluate(tt.ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,121 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/validators"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
type LabelProvider interface {
|
||||
GetLabels(appDomain string) (*model.App, error)
|
||||
Lookup(locator func(name string, app *model.App) bool) error
|
||||
}
|
||||
|
||||
type AccessControlsService struct {
|
||||
log *logger.Logger
|
||||
labelProvider *LabelProvider
|
||||
static map[string]model.App
|
||||
config *model.Config
|
||||
labelProvider LabelProvider
|
||||
}
|
||||
|
||||
func NewAccessControlsService(
|
||||
log *logger.Logger,
|
||||
labelProvider *LabelProvider,
|
||||
static map[string]model.App) *AccessControlsService {
|
||||
type AccessControlServiceInput struct {
|
||||
dig.In
|
||||
|
||||
Log *logger.Logger
|
||||
Config *model.Config
|
||||
LabelProvider LabelProvider `optional:"true"`
|
||||
}
|
||||
|
||||
func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsService {
|
||||
|
||||
return &AccessControlsService{
|
||||
log: log,
|
||||
labelProvider: labelProvider,
|
||||
static: static,
|
||||
log: i.Log,
|
||||
config: i.Config,
|
||||
labelProvider: i.LabelProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App {
|
||||
var appAcls *model.App
|
||||
for app, config := range acls.static {
|
||||
if config.Config.Domain == domain {
|
||||
acls.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
||||
appAcls = &config
|
||||
break // If we find a match by domain, we can stop searching
|
||||
}
|
||||
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
|
||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
||||
|
||||
if strings.SplitN(domain, ".", 2)[0] == app {
|
||||
acls.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
||||
appAcls = &config
|
||||
break // If we find a match by app name, we can stop searching
|
||||
var domainMatch *model.App
|
||||
var nameMatch *model.App
|
||||
var nameMatchedApps []string
|
||||
|
||||
locatorFunc := func(name string, app *model.App) bool {
|
||||
if app.Config.Domain != "" {
|
||||
err := v.Validate(app.Config.Domain, domain)
|
||||
if err == nil {
|
||||
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
|
||||
domainMatch = app
|
||||
return true
|
||||
} else if !errors.Is(err, validators.ErrHostnameMismatch) {
|
||||
service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed")
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) {
|
||||
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
|
||||
nameMatch = app
|
||||
nameMatchedApps = append(nameMatchedApps, name)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return appAcls
|
||||
|
||||
err := lookup(locatorFunc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if domainMatch != nil {
|
||||
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by domain")
|
||||
return domainMatch, nil
|
||||
}
|
||||
|
||||
if nameMatch == nil {
|
||||
service.log.App.Debug().Str("domain", domain).Msg("No match found for domain, skipping")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(nameMatchedApps) > 1 {
|
||||
service.log.App.Warn().Str("domain", domain).Strs("apps", nameMatchedApps).Msg("Multiple apps matched domain by name, app names must be unique, using last match")
|
||||
}
|
||||
|
||||
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
|
||||
return nameMatch, nil
|
||||
}
|
||||
|
||||
func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
|
||||
func (service *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) {
|
||||
return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error {
|
||||
for app, config := range service.config.Apps {
|
||||
if ok := locator(app, &config); ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
|
||||
// First check in the static config
|
||||
app := acls.lookupStaticACLs(domain)
|
||||
app, err := service.lookupStaticACLs(domain)
|
||||
|
||||
// Will never return an error here, but we need to check it
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if app != nil {
|
||||
acls.log.App.Debug().Msg("Using static ACLs for app")
|
||||
service.log.App.Debug().Msg("Using static ACLs for app")
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// If we have a label provider configured, try to get ACLs from it
|
||||
if acls.labelProvider != nil {
|
||||
return (*acls.labelProvider).GetLabels(domain)
|
||||
if service.labelProvider != nil {
|
||||
return service.getACLs(domain, service.labelProvider.Lookup)
|
||||
}
|
||||
|
||||
// no labels
|
||||
// No labels
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
type mockProvider struct {
|
||||
acls map[string]model.App
|
||||
shouldError bool
|
||||
}
|
||||
|
||||
func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider {
|
||||
return &mockProvider{acls: acls, shouldError: shouldError}
|
||||
}
|
||||
|
||||
func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error {
|
||||
if m.shouldError {
|
||||
return errors.New("mock error")
|
||||
}
|
||||
for name, app := range m.acls {
|
||||
if ok := locator(name, &app); ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAccessControlsService(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
domain string
|
||||
acls map[string]model.App
|
||||
want *model.App
|
||||
}{
|
||||
{
|
||||
name: "returns ACLs for domain",
|
||||
domain: "example.com",
|
||||
acls: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
||||
},
|
||||
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
||||
},
|
||||
{
|
||||
name: "returns ACLs for domain with port",
|
||||
domain: "example.com:8080",
|
||||
acls: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
||||
},
|
||||
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
||||
},
|
||||
{
|
||||
name: "returns ACLs for domain with trailing dot",
|
||||
domain: "example.com.",
|
||||
acls: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
||||
},
|
||||
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
||||
},
|
||||
{
|
||||
name: "returns ACLs for non-ascii domain",
|
||||
domain: "bücher.example.com",
|
||||
acls: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "bücher.example.com"}},
|
||||
},
|
||||
want: &model.App{Config: model.AppConfig{Domain: "bücher.example.com"}},
|
||||
},
|
||||
{
|
||||
name: "returns ACLs for punycode domain and non-ascii config",
|
||||
domain: "bücher.example.com",
|
||||
acls: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
|
||||
},
|
||||
want: &model.App{Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
|
||||
},
|
||||
{
|
||||
name: "returns ACLs with case-insensitive matching",
|
||||
domain: "Example.com",
|
||||
acls: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
||||
},
|
||||
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
||||
},
|
||||
{
|
||||
name: "falls back to name matching when domain fails",
|
||||
domain: "app.example.com",
|
||||
acls: map[string]model.App{
|
||||
"app": {Path: model.AppPath{Allow: "/foo"}},
|
||||
},
|
||||
want: &model.App{Path: model.AppPath{Allow: "/foo"}},
|
||||
},
|
||||
{
|
||||
name: "name matching is case-insensitive",
|
||||
domain: "aPp.example.com",
|
||||
acls: map[string]model.App{
|
||||
"APP": {Path: model.AppPath{Allow: "/foo"}},
|
||||
},
|
||||
want: &model.App{Path: model.AppPath{Allow: "/foo"}},
|
||||
},
|
||||
{
|
||||
name: "returns nil when no ACLs are found",
|
||||
domain: "example.com",
|
||||
acls: map[string]model.App{},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
// run once for a mock provider
|
||||
for _, test := range tests {
|
||||
t.Run(test.name+"(getACLs)", func(t *testing.T) {
|
||||
mock := newMockProvider(test.acls, false)
|
||||
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Config: &model.Config{},
|
||||
LabelProvider: mock,
|
||||
})
|
||||
app, err := acls.getACLs(test.domain, mock.Lookup)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.want, app)
|
||||
})
|
||||
}
|
||||
|
||||
// run again for static acls
|
||||
for _, test := range tests {
|
||||
t.Run(test.name+"(staticACLs)", func(t *testing.T) {
|
||||
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Config: &model.Config{
|
||||
Apps: test.acls,
|
||||
},
|
||||
})
|
||||
app, err := acls.lookupStaticACLs(test.domain)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.want, app)
|
||||
})
|
||||
}
|
||||
|
||||
// get acls should return an error when the provider fails
|
||||
mock := newMockProvider(map[string]model.App{}, true)
|
||||
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Config: &model.Config{},
|
||||
})
|
||||
_, err := acls.getACLs("example.com", mock.Lookup)
|
||||
require.Error(t, err)
|
||||
|
||||
// get access controls should get acls from
|
||||
// static when static acls are configured
|
||||
acls = NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Config: &model.Config{
|
||||
Apps: map[string]model.App{
|
||||
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
app, err := acls.GetAccessControls("foo.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app)
|
||||
|
||||
// should return nil for no apps
|
||||
app, err = acls.GetAccessControls("bar.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, app)
|
||||
|
||||
// Should use label provider if available
|
||||
mock = newMockProvider(map[string]model.App{
|
||||
"bar": {
|
||||
Config: model.AppConfig{Domain: "bar.example.com"},
|
||||
},
|
||||
}, false)
|
||||
acls = NewAccessControlsService(AccessControlServiceInput{
|
||||
Log: log,
|
||||
Config: &model.Config{},
|
||||
LabelProvider: mock,
|
||||
})
|
||||
app, err = acls.GetAccessControls("bar.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app)
|
||||
}
|
||||
+232
-434
@@ -2,23 +2,22 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/cache"
|
||||
"go.uber.org/dig"
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -28,37 +27,28 @@ import (
|
||||
// but for now these are just safety limits to prevent unbounded memory usage
|
||||
const MaxOAuthPendingSessions = 256
|
||||
const OAuthCleanupCount = 16
|
||||
const MaxLoginAttemptRecords = 256
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
)
|
||||
|
||||
// slightly modified version of the AuthorizeRequest from the OIDC service to basically accept all
|
||||
// parameters and pass them to the authorize page if needed
|
||||
type OAuthURLParams struct {
|
||||
Scope string `form:"scope" url:"scope"`
|
||||
ResponseType string `form:"response_type" url:"response_type"`
|
||||
ClientID string `form:"client_id" url:"client_id"`
|
||||
RedirectURI string `form:"redirect_uri" url:"redirect_uri"`
|
||||
State string `form:"state" url:"state"`
|
||||
Nonce string `form:"nonce" url:"nonce"`
|
||||
CodeChallenge string `form:"code_challenge" url:"code_challenge"`
|
||||
CodeChallengeMethod string `form:"code_challenge_method" url:"code_challenge_method"`
|
||||
// We either store params for redirecting to an app after OAuth login,
|
||||
// or for redirecting back to the authorize screen to continue OIDC
|
||||
type OAuthCallbackParams struct {
|
||||
LoginFor string `form:"login_for" url:"login_for"`
|
||||
OIDCTicket string `form:"oidc_ticket" url:"oidc_ticket"`
|
||||
OIDCScope string `form:"oidc_scope" url:"oidc_scope"`
|
||||
OIDCName string `form:"oidc_name" url:"oidc_name"`
|
||||
RedirectURI string `form:"redirect_uri" url:"redirect_uri"`
|
||||
}
|
||||
|
||||
type OAuthPendingSession struct {
|
||||
State string
|
||||
Verifier string
|
||||
Token *oauth2.Token
|
||||
Service *OAuthServiceImpl
|
||||
Service IOAuthService
|
||||
ExpiresAt time.Time
|
||||
CallbackParams OAuthURLParams
|
||||
}
|
||||
|
||||
type LdapGroupsCache struct {
|
||||
Groups []string
|
||||
Expires time.Time
|
||||
CallbackParams OAuthCallbackParams
|
||||
}
|
||||
|
||||
type LoginAttempt struct {
|
||||
@@ -67,58 +57,111 @@ type LoginAttempt struct {
|
||||
LockedUntil time.Time
|
||||
}
|
||||
|
||||
type Lockdown struct {
|
||||
Active bool
|
||||
ActiveUntil time.Time
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
context context.Context
|
||||
config *model.Config
|
||||
runtime *model.RuntimeConfig
|
||||
ctx context.Context
|
||||
|
||||
ldap *LdapService
|
||||
queries *repository.Queries
|
||||
oauthBroker *OAuthBrokerService
|
||||
ldap *LdapService
|
||||
queries repository.Store
|
||||
oauthBroker *OAuthBrokerService
|
||||
tailscale *TailscaleService
|
||||
policyEngine *PolicyEngine
|
||||
|
||||
loginAttempts map[string]*LoginAttempt
|
||||
ldapGroupsCache map[string]*LdapGroupsCache
|
||||
oauthPendingSessions map[string]*OAuthPendingSession
|
||||
oauthMutex sync.RWMutex
|
||||
loginMutex sync.RWMutex
|
||||
ldapGroupsMutex sync.RWMutex
|
||||
lockdown *Lockdown
|
||||
lockdownCtx context.Context
|
||||
lockdownCancelFunc context.CancelFunc
|
||||
dummyHash string
|
||||
|
||||
caches struct {
|
||||
login *cache.CacheStore[LoginAttempt]
|
||||
oauth *cache.CacheStore[OAuthPendingSession]
|
||||
ldap *cache.CacheStore[[]string]
|
||||
}
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
runtime model.RuntimeConfig,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
ldap *LdapService,
|
||||
queries *repository.Queries,
|
||||
oauthBroker *OAuthBrokerService,
|
||||
) *AuthService {
|
||||
type AuthServiceInput struct {
|
||||
dig.In
|
||||
|
||||
Log *logger.Logger
|
||||
Config *model.Config
|
||||
Runtime *model.RuntimeConfig
|
||||
Ctx context.Context
|
||||
Ding *ding.Ding
|
||||
LDAP *LdapService `optional:"true"`
|
||||
Queries repository.Store
|
||||
OAuthBroker *OAuthBrokerService
|
||||
Tailscale *TailscaleService `optional:"true"`
|
||||
PolicyEngine *PolicyEngine
|
||||
}
|
||||
|
||||
func NewAuthService(i AuthServiceInput) (*AuthService, error) {
|
||||
service := &AuthService{
|
||||
log: log,
|
||||
runtime: runtime,
|
||||
context: ctx,
|
||||
config: config,
|
||||
loginAttempts: make(map[string]*LoginAttempt),
|
||||
ldapGroupsCache: make(map[string]*LdapGroupsCache),
|
||||
oauthPendingSessions: make(map[string]*OAuthPendingSession),
|
||||
ldap: ldap,
|
||||
queries: queries,
|
||||
oauthBroker: oauthBroker,
|
||||
log: i.Log,
|
||||
runtime: i.Runtime,
|
||||
ctx: i.Ctx,
|
||||
config: i.Config,
|
||||
ldap: i.LDAP,
|
||||
queries: i.Queries,
|
||||
oauthBroker: i.OAuthBroker,
|
||||
tailscale: i.Tailscale,
|
||||
policyEngine: i.PolicyEngine,
|
||||
}
|
||||
|
||||
wg.Go(service.CleanupOAuthSessionsRoutine)
|
||||
// dummy hash
|
||||
dummyHash, err := bcrypt.GenerateFromPassword([]byte(utils.GenerateString(8)), bcrypt.DefaultCost)
|
||||
|
||||
return service
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate dummy hash: %w", err)
|
||||
}
|
||||
|
||||
service.dummyHash = string(dummyHash)
|
||||
|
||||
// caches setup
|
||||
oauthCache := cache.NewCacheStore[OAuthPendingSession](256)
|
||||
loginCache := cache.NewCacheStore[LoginAttempt](service.calculateLockdownLimit())
|
||||
ldapCache := cache.NewCacheStore[[]string](1024)
|
||||
|
||||
service.caches.oauth = oauthCache
|
||||
service.caches.login = loginCache
|
||||
service.caches.ldap = ldapCache
|
||||
|
||||
i.Ding.Go(func(ctx context.Context) {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.caches.oauth.Sweep()
|
||||
service.caches.login.Sweep()
|
||||
service.caches.ldap.Sweep()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}, ding.RingMinor)
|
||||
|
||||
i.Ding.Go(func(ctx context.Context) {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.log.App.Debug().Msg("Updating login cache limits")
|
||||
service.caches.login.SetMaxSize(service.calculateLockdownLimit())
|
||||
service.log.App.Debug().Msg("Login cache limits updated")
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}, ding.RingMinor)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) DummyPasswordCheck(password string) {
|
||||
bcrypt.CompareHashAndPassword([]byte(auth.dummyHash), []byte(password))
|
||||
}
|
||||
|
||||
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
|
||||
@@ -130,7 +173,7 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error)
|
||||
}
|
||||
|
||||
if auth.ldap != nil {
|
||||
userDN, email, err := auth.ldap.GetUserInfo(username)
|
||||
userDN, email, cn, err := auth.ldap.GetUserInfo(username)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ldap user: %w", err)
|
||||
@@ -139,6 +182,7 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error)
|
||||
return &model.UserSearch{
|
||||
Username: userDN,
|
||||
Email: email,
|
||||
Name: cn,
|
||||
Type: model.UserLDAP,
|
||||
}, nil
|
||||
}
|
||||
@@ -191,14 +235,12 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
return nil, errors.New("ldap service not configured")
|
||||
}
|
||||
|
||||
auth.ldapGroupsMutex.RLock()
|
||||
entry, exists := auth.ldapGroupsCache[userDN]
|
||||
auth.ldapGroupsMutex.RUnlock()
|
||||
entry, exists := auth.caches.ldap.Get(userDN)
|
||||
|
||||
if exists && time.Now().Before(entry.Expires) {
|
||||
if exists {
|
||||
return &model.LDAPUser{
|
||||
DN: userDN,
|
||||
Groups: entry.Groups,
|
||||
Groups: entry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -208,12 +250,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
return nil, fmt.Errorf("failed to get ldap groups: %w", err)
|
||||
}
|
||||
|
||||
auth.ldapGroupsMutex.Lock()
|
||||
auth.ldapGroupsCache[userDN] = &LdapGroupsCache{
|
||||
Groups: groups,
|
||||
Expires: time.Now().Add(time.Duration(auth.config.LDAP.GroupCacheTTL) * time.Second),
|
||||
}
|
||||
auth.ldapGroupsMutex.Unlock()
|
||||
auth.caches.ldap.Set(userDN, groups, time.Duration(auth.config.LDAP.GroupCacheTTL)*time.Second)
|
||||
|
||||
return &model.LDAPUser{
|
||||
DN: userDN,
|
||||
@@ -222,19 +259,11 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
||||
auth.loginMutex.RLock()
|
||||
defer auth.loginMutex.RUnlock()
|
||||
|
||||
if auth.lockdown != nil && auth.lockdown.Active {
|
||||
remaining := int(time.Until(auth.lockdown.ActiveUntil).Seconds())
|
||||
return true, remaining
|
||||
}
|
||||
|
||||
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
attempt, exists := auth.loginAttempts[identifier]
|
||||
attempt, exists := auth.caches.login.Get(identifier)
|
||||
if !exists {
|
||||
return false, 0
|
||||
}
|
||||
@@ -252,45 +281,72 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.loginMutex.Lock()
|
||||
defer auth.loginMutex.Unlock()
|
||||
auth.caches.login.WithLock(func(actions cache.CacheStoreActions[LoginAttempt]) {
|
||||
entry, ok := actions.Get(identifier)
|
||||
|
||||
if len(auth.loginAttempts) >= MaxLoginAttemptRecords {
|
||||
if auth.lockdown != nil && auth.lockdown.Active {
|
||||
if !ok {
|
||||
attempt := LoginAttempt{
|
||||
LastAttempt: time.Now(),
|
||||
}
|
||||
if !success {
|
||||
attempt.FailedAttempts = 1
|
||||
if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
|
||||
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
|
||||
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts")
|
||||
}
|
||||
}
|
||||
// match current tinyauth behavior which doesn't expire rate limits
|
||||
actions.Set(identifier, attempt, 0)
|
||||
return
|
||||
}
|
||||
go auth.lockdownMode()
|
||||
return
|
||||
}
|
||||
|
||||
attempt, exists := auth.loginAttempts[identifier]
|
||||
if !exists {
|
||||
attempt = &LoginAttempt{}
|
||||
auth.loginAttempts[identifier] = attempt
|
||||
}
|
||||
entry.LastAttempt = time.Now()
|
||||
|
||||
attempt.LastAttempt = time.Now()
|
||||
if success {
|
||||
entry.FailedAttempts = 0
|
||||
entry.LockedUntil = time.Time{}
|
||||
} else {
|
||||
entry.FailedAttempts++
|
||||
|
||||
if success {
|
||||
attempt.FailedAttempts = 0
|
||||
attempt.LockedUntil = time.Time{} // Reset lock time
|
||||
return
|
||||
}
|
||||
if entry.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
|
||||
entry.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
|
||||
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", entry.FailedAttempts).Msg("Account locked due to too many failed login attempts")
|
||||
}
|
||||
}
|
||||
|
||||
attempt.FailedAttempts++
|
||||
|
||||
if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
|
||||
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
|
||||
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts")
|
||||
}
|
||||
actions.Set(identifier, entry, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsEmailWhitelisted(email string) bool {
|
||||
return utils.CheckFilter(strings.Join(auth.runtime.OAuthWhitelist, ","), email)
|
||||
// We could also directly access the policyEngine.effectToAccess but
|
||||
// I believe it's better to use the exported functions instead
|
||||
func (auth *AuthService) IsEmailWhitelisted(provider string, email string) bool {
|
||||
return auth.policyEngine.EvaluateFunc(func() Effect {
|
||||
whitelist := auth.runtime.OAuthWhitelist
|
||||
if providerConfig, ok := auth.runtime.OAuthProviders[provider]; ok && len(providerConfig.Whitelist) > 0 {
|
||||
whitelist = providerConfig.Whitelist
|
||||
}
|
||||
match, err := utils.CheckFilter(strings.Join(whitelist, ","), email)
|
||||
if err != nil {
|
||||
if err == utils.ErrFilterEmpty {
|
||||
return EffectAbstain
|
||||
}
|
||||
auth.log.App.Error().Err(err).Str("email", email).Msg("Failed to evaluate email whitelist filter, defaulting to deny")
|
||||
return EffectDeny
|
||||
}
|
||||
if match {
|
||||
return EffectAllow
|
||||
}
|
||||
return EffectDeny
|
||||
})
|
||||
}
|
||||
|
||||
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) {
|
||||
uuid, err := uuid.NewRandom()
|
||||
if data.Provider == "tailscale" && auth.tailscale == nil {
|
||||
return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user")
|
||||
}
|
||||
|
||||
u, err := uuid.NewRandom()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session uuid: %w", err)
|
||||
@@ -307,7 +363,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
||||
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
|
||||
|
||||
session := repository.CreateSessionParams{
|
||||
UUID: uuid.String(),
|
||||
UUID: u.String(),
|
||||
Username: data.Username,
|
||||
Email: data.Email,
|
||||
Name: data.Name,
|
||||
@@ -330,7 +386,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Value: session.UUID,
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Domain: auth.getCookieDomain(),
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
@@ -383,7 +439,7 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Value: session.UUID,
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Domain: auth.getCookieDomain(),
|
||||
Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second),
|
||||
MaxAge: int(newExpiry - currentTime),
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
@@ -404,7 +460,7 @@ func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Domain: auth.getCookieDomain(),
|
||||
Expires: time.Now(),
|
||||
MaxAge: -1,
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
@@ -417,7 +473,7 @@ func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*reposito
|
||||
session, err := auth.queries.GetSession(ctx, uuid)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, errors.New("session not found")
|
||||
}
|
||||
return nil, err
|
||||
@@ -454,184 +510,17 @@ func (auth *AuthService) LDAPAuthConfigured() bool {
|
||||
return auth.ldap != nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsUserAllowed(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
||||
if acls == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if context.Provider == model.ProviderOAuth {
|
||||
auth.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
|
||||
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
|
||||
}
|
||||
|
||||
if acls.Users.Block != "" {
|
||||
auth.log.App.Debug().Msg("Checking users block list")
|
||||
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Msg("Checking users allow list")
|
||||
return utils.CheckFilter(acls.Users.Allow, context.GetUsername())
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
||||
if acls == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if !context.IsOAuth() {
|
||||
auth.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
|
||||
auth.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check")
|
||||
return true
|
||||
}
|
||||
|
||||
for _, userGroup := range context.OAuth.Groups {
|
||||
if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) {
|
||||
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Msg("No groups matched")
|
||||
return false
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsInLDAPGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
||||
if acls == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if !context.IsLDAP() {
|
||||
auth.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
|
||||
return false
|
||||
}
|
||||
|
||||
for _, userGroup := range context.LDAP.Groups {
|
||||
if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) {
|
||||
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Msg("No groups matched")
|
||||
return false
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error) {
|
||||
if acls == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check for block list
|
||||
if acls.Path.Block != "" {
|
||||
regex, err := regexp.Compile(acls.Path.Block)
|
||||
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
if !regex.MatchString(uri) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check for allow list
|
||||
if acls.Path.Allow != "" {
|
||||
regex, err := regexp.Compile(acls.Path.Allow)
|
||||
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
if regex.MatchString(uri) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
|
||||
if acls == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Merge the global and app IP filter
|
||||
blockedIps := append(auth.config.Auth.IP.Block, acls.IP.Block...)
|
||||
allowedIPs := append(auth.config.Auth.IP.Allow, acls.IP.Allow...)
|
||||
|
||||
for _, blocked := range blockedIps {
|
||||
res, err := utils.FilterIP(blocked, ip)
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
||||
continue
|
||||
}
|
||||
if res {
|
||||
auth.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for _, allowed := range allowedIPs {
|
||||
res, err := utils.FilterIP(allowed, ip)
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
||||
continue
|
||||
}
|
||||
if res {
|
||||
auth.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowedIPs) > 0 {
|
||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
|
||||
return false
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in any block or allow list, allowing access by default")
|
||||
return true
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsBypassedIP(ip string, acls *model.App) bool {
|
||||
if acls == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, bypassed := range acls.IP.Bypass {
|
||||
res, err := utils.FilterIP(bypassed, ip)
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
||||
continue
|
||||
}
|
||||
if res {
|
||||
auth.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication")
|
||||
return false
|
||||
}
|
||||
|
||||
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
|
||||
auth.ensureOAuthSessionLimit()
|
||||
|
||||
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthCallbackParams) (string, error) {
|
||||
service, ok := auth.oauthBroker.GetService(serviceName)
|
||||
|
||||
if !ok {
|
||||
return "", OAuthPendingSession{}, fmt.Errorf("oauth service not found: %s", serviceName)
|
||||
return "", fmt.Errorf("oauth service not found: %s", serviceName)
|
||||
}
|
||||
|
||||
sessionId, err := uuid.NewRandom()
|
||||
|
||||
if err != nil {
|
||||
return "", OAuthPendingSession{}, fmt.Errorf("failed to generate session ID: %w", err)
|
||||
return "", fmt.Errorf("failed to generate session ID: %w", err)
|
||||
}
|
||||
|
||||
state := service.NewRandom()
|
||||
@@ -640,16 +529,14 @@ func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLPara
|
||||
session := OAuthPendingSession{
|
||||
State: state,
|
||||
Verifier: verifier,
|
||||
Service: &service,
|
||||
Service: service,
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
CallbackParams: params,
|
||||
}
|
||||
|
||||
auth.oauthMutex.Lock()
|
||||
auth.oauthPendingSessions[sessionId.String()] = &session
|
||||
auth.oauthMutex.Unlock()
|
||||
auth.caches.oauth.Set(sessionId.String(), session, time.Minute*10)
|
||||
|
||||
return sessionId.String(), session, nil
|
||||
return sessionId.String(), nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) {
|
||||
@@ -659,25 +546,30 @@ func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return (*session.Service).GetAuthURL(session.State, session.Verifier), nil
|
||||
return session.Service.GetAuthURL(session.State, session.Verifier), nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.Token, error) {
|
||||
session, err := auth.GetOAuthPendingSession(sessionId)
|
||||
session, ok := auth.caches.oauth.Get(sessionId)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("oauth session not found: %s", sessionId)
|
||||
}
|
||||
|
||||
token, err := (*session.Service).GetToken(code, session.Verifier)
|
||||
token, err := session.Service.GetToken(code, session.Verifier)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
|
||||
}
|
||||
|
||||
auth.oauthMutex.Lock()
|
||||
session.Token = token
|
||||
auth.oauthMutex.Unlock()
|
||||
|
||||
// ttl 0 means keep current expiration
|
||||
ok = auth.caches.oauth.Update(sessionId, session, 0)
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to update oauth session with token: %s", sessionId)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
@@ -693,7 +585,7 @@ func (auth *AuthService) GetOAuthUserinfo(sessionId string) (*model.Claims, erro
|
||||
return nil, fmt.Errorf("oauth token not found for session: %s", sessionId)
|
||||
}
|
||||
|
||||
userinfo, err := (*session.Service).GetUserinfo(session.Token)
|
||||
userinfo, err := session.Service.GetUserinfo(session.Token)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get userinfo: %w", err)
|
||||
@@ -702,161 +594,67 @@ func (auth *AuthService) GetOAuthUserinfo(sessionId string) (*model.Claims, erro
|
||||
return userinfo, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetOAuthService(sessionId string) (OAuthServiceImpl, error) {
|
||||
func (auth *AuthService) GetOAuthService(sessionId string) (IOAuthService, error) {
|
||||
session, err := auth.GetOAuthPendingSession(sessionId)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return *session.Service, nil
|
||||
return session.Service, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) EndOAuthSession(sessionId string) {
|
||||
auth.oauthMutex.Lock()
|
||||
delete(auth.oauthPendingSessions, sessionId)
|
||||
auth.oauthMutex.Unlock()
|
||||
}
|
||||
|
||||
func (auth *AuthService) CleanupOAuthSessionsRoutine() {
|
||||
auth.log.App.Debug().Msg("Starting OAuth session cleanup routine")
|
||||
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
auth.log.App.Debug().Msg("Running OAuth session cleanup")
|
||||
|
||||
auth.oauthMutex.Lock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for sessionId, session := range auth.oauthPendingSessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(auth.oauthPendingSessions, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
auth.oauthMutex.Unlock()
|
||||
auth.log.App.Debug().Msg("OAuth session cleanup completed")
|
||||
case <-auth.context.Done():
|
||||
auth.log.App.Debug().Msg("Stopping OAuth session cleanup routine")
|
||||
return
|
||||
}
|
||||
}
|
||||
auth.caches.oauth.Delete(sessionId)
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetOAuthPendingSession(sessionId string) (*OAuthPendingSession, error) {
|
||||
auth.ensureOAuthSessionLimit()
|
||||
|
||||
auth.oauthMutex.RLock()
|
||||
session, exists := auth.oauthPendingSessions[sessionId]
|
||||
auth.oauthMutex.RUnlock()
|
||||
session, exists := auth.caches.oauth.Get(sessionId)
|
||||
|
||||
if !exists {
|
||||
return &OAuthPendingSession{}, fmt.Errorf("oauth session not found: %s", sessionId)
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
auth.oauthMutex.Lock()
|
||||
delete(auth.oauthPendingSessions, sessionId)
|
||||
auth.oauthMutex.Unlock()
|
||||
return &OAuthPendingSession{}, fmt.Errorf("oauth session expired: %s", sessionId)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) ensureOAuthSessionLimit() {
|
||||
auth.oauthMutex.Lock()
|
||||
defer auth.oauthMutex.Unlock()
|
||||
// ClearLoginAttempts is a testing function, not useful for anything else
|
||||
func (auth *AuthService) ClearLoginAttempts() {
|
||||
auth.caches.login.Clear()
|
||||
}
|
||||
|
||||
if len(auth.oauthPendingSessions) <= MaxOAuthPendingSessions {
|
||||
return
|
||||
}
|
||||
func (auth *AuthService) calculateLockdownLimit() int {
|
||||
userCount := len(auth.runtime.LocalUsers)
|
||||
|
||||
type entry struct {
|
||||
id string
|
||||
expiresAt int64
|
||||
}
|
||||
|
||||
entries := make([]entry, 0, len(auth.oauthPendingSessions))
|
||||
for id, session := range auth.oauthPendingSessions {
|
||||
entries = append(entries, entry{id, session.ExpiresAt.Unix()})
|
||||
}
|
||||
|
||||
slices.SortFunc(entries, func(a, b entry) int {
|
||||
if a.expiresAt < b.expiresAt {
|
||||
return -1
|
||||
if auth.ldap != nil {
|
||||
ldapUsers, err := auth.ldap.GetUserCount()
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Msg("Failed to get LDAP user count")
|
||||
} else {
|
||||
userCount += ldapUsers
|
||||
}
|
||||
if a.expiresAt > b.expiresAt {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
for _, e := range entries[:OAuthCleanupCount] {
|
||||
delete(auth.oauthPendingSessions, e.id)
|
||||
}
|
||||
|
||||
limit := userCount * auth.config.Auth.LoginMaxRetries
|
||||
|
||||
jitter, err := rand.Int(rand.Reader, big.NewInt(64))
|
||||
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Msg("Failed to generate jitter for lockdown limit")
|
||||
} else {
|
||||
limit += int(jitter.Int64())
|
||||
}
|
||||
|
||||
if limit < 256 {
|
||||
limit = 256
|
||||
}
|
||||
|
||||
return limit
|
||||
}
|
||||
|
||||
func (auth *AuthService) lockdownMode() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
auth.loginMutex.Lock()
|
||||
|
||||
if auth.lockdown != nil && auth.lockdown.Active {
|
||||
auth.loginMutex.Unlock()
|
||||
cancel()
|
||||
return
|
||||
func (auth *AuthService) getCookieDomain() string {
|
||||
if !auth.config.Auth.SubdomainsEnabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
auth.lockdownCtx = ctx
|
||||
auth.lockdownCancelFunc = cancel
|
||||
|
||||
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
|
||||
|
||||
auth.lockdown = &Lockdown{
|
||||
Active: true,
|
||||
ActiveUntil: time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second),
|
||||
}
|
||||
|
||||
// At this point all login attemps will also expire so,
|
||||
// we might as well clear them to free up memory
|
||||
auth.loginAttempts = make(map[string]*LoginAttempt)
|
||||
|
||||
timer := time.NewTimer(time.Until(auth.lockdown.ActiveUntil))
|
||||
|
||||
auth.loginMutex.Unlock()
|
||||
|
||||
defer cancel()
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Timer expired, end lockdown
|
||||
case <-ctx.Done():
|
||||
// Context cancelled, end lockdown
|
||||
case <-auth.context.Done():
|
||||
// Service is shutting down, end lockdown
|
||||
}
|
||||
|
||||
auth.loginMutex.Lock()
|
||||
|
||||
auth.log.App.Info().Msg("Exiting lockdown mode")
|
||||
|
||||
auth.lockdown = nil
|
||||
auth.loginMutex.Unlock()
|
||||
}
|
||||
|
||||
// Function only used for testing - do not use in prod!
|
||||
func (auth *AuthService) ClearRateLimitsTestingOnly() {
|
||||
auth.loginMutex.Lock()
|
||||
auth.loginAttempts = make(map[string]*LoginAttempt)
|
||||
if auth.lockdown != nil {
|
||||
auth.lockdownCancelFunc()
|
||||
}
|
||||
auth.loginMutex.Unlock()
|
||||
return auth.runtime.CookieDomain
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func TestIsEmailWhitelistedUsesProviderSpecificList(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
policyEngine, err := NewPolicyEngine(PolicyEngineInput{
|
||||
Log: log,
|
||||
Config: &model.Config{
|
||||
Auth: model.AuthConfig{
|
||||
ACLs: model.ACLsConfig{
|
||||
Policy: string(PolicyAllow),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
auth := &AuthService{
|
||||
log: log,
|
||||
runtime: &model.RuntimeConfig{
|
||||
OAuthWhitelist: []string{"global@example.com"},
|
||||
OAuthProviders: map[string]model.OAuthServiceConfig{
|
||||
"github": {
|
||||
Whitelist: []string{"github@example.com"},
|
||||
},
|
||||
"pocketid": {
|
||||
Whitelist: []string{"pocket@example.com"},
|
||||
},
|
||||
"gitlab": {
|
||||
Whitelist: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
policyEngine: policyEngine,
|
||||
}
|
||||
|
||||
assert.True(t, auth.IsEmailWhitelisted("github", "github@example.com"))
|
||||
assert.False(t, auth.IsEmailWhitelisted("github", "pocket@example.com"))
|
||||
assert.True(t, auth.IsEmailWhitelisted("pocketid", "pocket@example.com"))
|
||||
assert.True(t, auth.IsEmailWhitelisted("google", "global@example.com"))
|
||||
assert.True(t, auth.IsEmailWhitelisted("gitlab", "global@example.com"))
|
||||
assert.False(t, auth.IsEmailWhitelisted("gitlab", "unknown@example.com"))
|
||||
}
|
||||
@@ -2,12 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"fmt"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"go.uber.org/dig"
|
||||
|
||||
container "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
@@ -21,36 +22,39 @@ type DockerService struct {
|
||||
isConnected bool
|
||||
}
|
||||
|
||||
func NewDockerService(
|
||||
log *logger.Logger,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
) (*DockerService, error) {
|
||||
type DockerServiceInput struct {
|
||||
dig.In
|
||||
|
||||
Log *logger.Logger
|
||||
Ctx context.Context
|
||||
Ding *ding.Ding
|
||||
}
|
||||
|
||||
func NewDockerService(i DockerServiceInput) (*DockerService, error) {
|
||||
client, err := client.NewClientWithOpts(client.FromEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.NegotiateAPIVersion(ctx)
|
||||
client.NegotiateAPIVersion(i.Ctx)
|
||||
|
||||
_, err = client.Ping(ctx)
|
||||
_, err = client.Ping(i.Ctx)
|
||||
|
||||
if err != nil {
|
||||
log.App.Debug().Err(err).Msg("Docker not connected")
|
||||
i.Log.App.Debug().Err(err).Msg("Docker not connected")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
service := &DockerService{
|
||||
log: log,
|
||||
log: i.Log,
|
||||
client: client,
|
||||
context: ctx,
|
||||
context: i.Ctx,
|
||||
}
|
||||
|
||||
service.isConnected = true
|
||||
service.log.App.Debug().Msg("Docker connected successfully")
|
||||
|
||||
wg.Go(service.watchAndClose)
|
||||
i.Ding.Go(service.watchAndClose, ding.RingMajor)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
@@ -63,47 +67,42 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
|
||||
return docker.client.ContainerInspect(docker.context, containerId)
|
||||
}
|
||||
|
||||
func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
|
||||
func (docker *DockerService) Lookup(locator func(name string, app *model.App) bool) error {
|
||||
if !docker.isConnected {
|
||||
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
|
||||
return nil, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
containers, err := docker.getContainers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return fmt.Errorf("failed to get containers: %w", err)
|
||||
}
|
||||
|
||||
for _, ctr := range containers {
|
||||
inspect, err := docker.inspectContainer(ctr.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
docker.log.App.Error().Err(err).Msgf("Failed to inspect container %s", ctr.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
docker.log.App.Warn().Err(err).Msgf("Failed to decode labels for container %s", ctr.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
for appName, appLabels := range labels.Apps {
|
||||
if appLabels.Config.Domain == appDomain {
|
||||
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
|
||||
return &appLabels, nil
|
||||
}
|
||||
|
||||
if strings.SplitN(appDomain, ".", 2)[0] == appName {
|
||||
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
|
||||
return &appLabels, nil
|
||||
for app, config := range labels.Apps {
|
||||
if ok := locator(app, &config); ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
docker.log.App.Debug().Str("domain", appDomain).Msg("No matching container found for domain")
|
||||
return nil, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (docker *DockerService) watchAndClose() {
|
||||
<-docker.context.Done()
|
||||
func (docker *DockerService) watchAndClose(ctx context.Context) {
|
||||
<-ctx.Done()
|
||||
docker.log.App.Debug().Msg("Closing Docker client")
|
||||
if docker.client != nil {
|
||||
err := docker.client.Close()
|
||||
|
||||
@@ -3,13 +3,17 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/validators"
|
||||
"go.uber.org/dig"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -19,57 +23,82 @@ import (
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
// watchedResource describes a kind of resource that can carry tinyauth
|
||||
// annotations, along with the specifics of extracting the hosts it routes.
|
||||
type watchedResource struct {
|
||||
gvr schema.GroupVersionResource
|
||||
// gatewayAPI resources declare their hosts in spec.hostnames instead of
|
||||
// spec.rules[].host and may use the wildcard label (`*.`).
|
||||
gatewayAPI bool
|
||||
// httpPaths marks resources that route on HTTP paths, which means another
|
||||
// resource may claim the same host on a different path.
|
||||
httpPaths bool
|
||||
}
|
||||
|
||||
// api returns a human readable identifier for the watched resource.
|
||||
func (r watchedResource) api() string {
|
||||
return r.gvr.GroupVersion().String() + "/" + r.gvr.Resource
|
||||
}
|
||||
|
||||
var watchedResources = []watchedResource{
|
||||
{
|
||||
gvr: schema.GroupVersionResource{
|
||||
Group: "networking.k8s.io",
|
||||
Version: "v1",
|
||||
Resource: "ingresses",
|
||||
},
|
||||
httpPaths: true,
|
||||
},
|
||||
{
|
||||
gvr: schema.GroupVersionResource{
|
||||
Group: "gateway.networking.k8s.io",
|
||||
Version: "v1",
|
||||
Resource: "httproutes",
|
||||
},
|
||||
gatewayAPI: true,
|
||||
httpPaths: true,
|
||||
},
|
||||
{
|
||||
gvr: schema.GroupVersionResource{
|
||||
Group: "gateway.networking.k8s.io",
|
||||
Version: "v1",
|
||||
Resource: "grpcroutes",
|
||||
},
|
||||
gatewayAPI: true,
|
||||
},
|
||||
}
|
||||
|
||||
type resourceEntry struct {
|
||||
name string
|
||||
app model.App
|
||||
}
|
||||
|
||||
// resourceKey identifies a watched resource. The kind is part of the key
|
||||
// because an Ingress and an HTTPRoute may share a name within a namespace.
|
||||
type resourceKey struct {
|
||||
resource string
|
||||
namespace string
|
||||
name string
|
||||
}
|
||||
|
||||
type resourceAppKey struct {
|
||||
resourceKey
|
||||
appName string
|
||||
}
|
||||
|
||||
type resourceApp struct {
|
||||
domain string
|
||||
appName string
|
||||
app model.App
|
||||
}
|
||||
|
||||
type KubernetesService struct {
|
||||
log *logger.Logger
|
||||
ctx context.Context
|
||||
|
||||
client dynamic.Interface
|
||||
started bool
|
||||
mu sync.RWMutex
|
||||
resourceApps map[resourceKey][]resourceApp
|
||||
domainIndex map[string]resourceAppKey
|
||||
appNameIndex map[string]resourceAppKey
|
||||
client dynamic.Interface
|
||||
connected bool
|
||||
mu sync.RWMutex
|
||||
resourceEntries map[resourceKey][]resourceEntry
|
||||
}
|
||||
|
||||
var watchedGVRs = []schema.GroupVersionResource{
|
||||
{
|
||||
Group: "networking.k8s.io",
|
||||
Version: "v1",
|
||||
Resource: "ingresses",
|
||||
},
|
||||
{
|
||||
Group: "gateway.networking.k8s.io",
|
||||
Version: "v1",
|
||||
Resource: "httproutes",
|
||||
},
|
||||
{
|
||||
Group: "gateway.networking.k8s.io",
|
||||
Version: "v1",
|
||||
Resource: "grpcroutes",
|
||||
},
|
||||
type KubernetesServiceInput struct {
|
||||
dig.In
|
||||
|
||||
Log *logger.Logger
|
||||
Ctx context.Context
|
||||
Ding *ding.Ding
|
||||
}
|
||||
|
||||
func NewKubernetesService(
|
||||
log *logger.Logger,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
) (*KubernetesService, error) {
|
||||
func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) {
|
||||
cfg, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get in-cluster kubernetes config: %w", err)
|
||||
@@ -81,222 +110,421 @@ func NewKubernetesService(
|
||||
}
|
||||
|
||||
service := &KubernetesService{
|
||||
log: log,
|
||||
ctx: ctx,
|
||||
client: client,
|
||||
resourceApps: make(map[resourceKey][]resourceApp),
|
||||
domainIndex: make(map[string]resourceAppKey),
|
||||
appNameIndex: make(map[string]resourceAppKey),
|
||||
log: i.Log,
|
||||
client: client,
|
||||
resourceEntries: make(map[resourceKey][]resourceEntry),
|
||||
}
|
||||
|
||||
accessCtx, accessCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer accessCancel()
|
||||
watching := 0
|
||||
|
||||
for _, res := range watchedResources {
|
||||
accessCtx, accessCancel := context.WithTimeout(i.Ctx, 5*time.Second)
|
||||
_, err := client.Resource(res.gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
|
||||
accessCancel()
|
||||
|
||||
started := 0
|
||||
for _, gvr := range watchedGVRs {
|
||||
_, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
|
||||
if err != nil {
|
||||
log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to access API, skipping watcher")
|
||||
// The Gateway API CRDs are not installed on every cluster, so a
|
||||
// single unreachable API is not fatal
|
||||
i.Log.App.Warn().Err(err).Str("api", res.api()).Msg("Failed to access API, skipping watcher")
|
||||
continue
|
||||
}
|
||||
log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed API, starting watcher")
|
||||
gvrCopy := gvr
|
||||
wg.Go(func() {
|
||||
service.watchGVR(gvrCopy)
|
||||
})
|
||||
started++
|
||||
|
||||
i.Log.App.Debug().Str("api", res.api()).Msg("Successfully accessed API, starting watcher")
|
||||
|
||||
i.Ding.Go(func(ctx context.Context) {
|
||||
service.watchGVR(res, ctx)
|
||||
}, ding.RingMajor)
|
||||
|
||||
watching++
|
||||
}
|
||||
|
||||
if started == 0 {
|
||||
return nil, fmt.Errorf("failed to access any supported kubernetes API (ingresses, httproutes)")
|
||||
if watching == 0 {
|
||||
return nil, fmt.Errorf("failed to access any supported kubernetes api (ingresses, httproutes, grpcroutes)")
|
||||
}
|
||||
|
||||
service.started = true
|
||||
log.App.Debug().Msg("Kubernetes label provider started successfully")
|
||||
service.connected = true
|
||||
i.Log.App.Debug().Msg("Kubernetes label provider started successfully")
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) addResourceApps(namespace, name string, apps []resourceApp) {
|
||||
func (k *KubernetesService) addResourceEntries(key resourceKey, entries []resourceEntry) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
key := resourceKey{namespace, name}
|
||||
// Remove existing entries for this resource
|
||||
if existing, ok := k.resourceApps[key]; ok {
|
||||
for _, app := range existing {
|
||||
delete(k.domainIndex, app.domain)
|
||||
delete(k.appNameIndex, app.appName)
|
||||
}
|
||||
}
|
||||
// Add new entries
|
||||
k.resourceApps[key] = apps
|
||||
for _, app := range apps {
|
||||
appKey := resourceAppKey{key, app.appName}
|
||||
k.domainIndex[app.domain] = appKey
|
||||
k.appNameIndex[app.appName] = appKey
|
||||
}
|
||||
k.resourceEntries[key] = entries
|
||||
}
|
||||
|
||||
func (k *KubernetesService) removeResource(namespace, name string) {
|
||||
func (k *KubernetesService) removeResource(key resourceKey) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
key := resourceKey{namespace, name}
|
||||
if apps, ok := k.resourceApps[key]; ok {
|
||||
for _, app := range apps {
|
||||
delete(k.domainIndex, app.domain)
|
||||
delete(k.appNameIndex, app.appName)
|
||||
}
|
||||
delete(k.resourceApps, key)
|
||||
}
|
||||
delete(k.resourceEntries, key)
|
||||
}
|
||||
|
||||
func (k *KubernetesService) getByDomain(domain string) *model.App {
|
||||
func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
|
||||
if appKey, ok := k.domainIndex[domain]; ok {
|
||||
if apps, ok := k.resourceApps[appKey.resourceKey]; ok {
|
||||
for i := range apps {
|
||||
app := &apps[i]
|
||||
if app.domain == domain && app.appName == appKey.appName {
|
||||
return &app.app
|
||||
}
|
||||
// O(n^2) is not great but the number of resource entries is expected to be small
|
||||
for _, entries := range k.resourceEntries {
|
||||
for _, entry := range entries {
|
||||
if ok := locator(entry.name, &entry.app); ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) getByAppName(appName string) *model.App {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
// hostMatches reports whether hostname is routed by host. It honours the
|
||||
// Gateway API wildcard label (`*.`), which is a suffix match, so
|
||||
// `*.example.com` matches `test.example.com` and `foo.test.example.com` but
|
||||
// not `example.com`.
|
||||
func hostMatches(host string, hostname string) bool {
|
||||
host = strings.ToLower(host)
|
||||
|
||||
if appKey, ok := k.appNameIndex[appName]; ok {
|
||||
if apps, ok := k.resourceApps[appKey.resourceKey]; ok {
|
||||
for i := range apps {
|
||||
app := &apps[i]
|
||||
if app.appName == appName {
|
||||
return &app.app
|
||||
}
|
||||
}
|
||||
}
|
||||
if suffix, ok := strings.CutPrefix(host, "*."); ok {
|
||||
return strings.HasSuffix(hostname, "."+suffix)
|
||||
}
|
||||
return nil
|
||||
|
||||
return host == hostname
|
||||
}
|
||||
|
||||
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
||||
namespace := item.GetNamespace()
|
||||
name := item.GetName()
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
k.removeResource(namespace, name)
|
||||
return
|
||||
// hostCoversName reports whether an app name could resolve to a host routed by
|
||||
// the resource. A wildcard host covers any app name since `*.example.com`
|
||||
// routes `<name>.example.com` for every name.
|
||||
func hostCoversName(host string, name string) bool {
|
||||
host = strings.ToLower(host)
|
||||
|
||||
if strings.HasPrefix(host, "*.") {
|
||||
return true
|
||||
}
|
||||
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
|
||||
|
||||
return strings.HasPrefix(host, strings.ToLower(name+"."))
|
||||
}
|
||||
|
||||
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
|
||||
http, found, err := unstructured.NestedMap(rule, "http")
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode labels, skipping")
|
||||
k.removeResource(namespace, name)
|
||||
return
|
||||
return nil, fmt.Errorf("reading http from rule: %w", err)
|
||||
}
|
||||
var apps []resourceApp
|
||||
for appName, appLabels := range labels.Apps {
|
||||
if appLabels.Config.Domain == "" {
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
paths, found, err := unstructured.NestedSlice(http, "paths")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading http.paths: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
var result []string
|
||||
for _, p := range paths {
|
||||
path, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
apps = append(apps, resourceApp{
|
||||
domain: appLabels.Config.Domain,
|
||||
appName: appName,
|
||||
app: appLabels,
|
||||
})
|
||||
}
|
||||
if len(apps) == 0 {
|
||||
k.removeResource(namespace, name)
|
||||
} else {
|
||||
k.addResourceApps(namespace, name, apps)
|
||||
if p, ok := path["path"].(string); ok && p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error {
|
||||
ctx, cancel := context.WithTimeout(k.ctx, 30*time.Second)
|
||||
// extractRoutePaths returns the paths matched by a Gateway API route rule and
|
||||
// whether the rule matches every path for its hosts.
|
||||
func (k *KubernetesService) extractRoutePaths(rule map[string]any) ([]string, bool, error) {
|
||||
matches, found, err := unstructured.NestedSlice(rule, "matches")
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("reading matches from rule: %w", err)
|
||||
}
|
||||
if !found || len(matches) == 0 {
|
||||
// An omitted matches list defaults to a PathPrefix match on "/"
|
||||
return nil, true, nil
|
||||
}
|
||||
var result []string
|
||||
catchAll := false
|
||||
for _, m := range matches {
|
||||
match, ok := m.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path, ok := match["path"].(map[string]any)
|
||||
if !ok {
|
||||
// A match without a path constrains something else, such as headers
|
||||
// or a gRPC method, and leaves the path unrestricted
|
||||
catchAll = true
|
||||
continue
|
||||
}
|
||||
// Both fields are optional and default to a PathPrefix match on "/"
|
||||
pathType, ok := path["type"].(string)
|
||||
if !ok || pathType == "" {
|
||||
pathType = "PathPrefix"
|
||||
}
|
||||
value, ok := path["value"].(string)
|
||||
if !ok || value == "" {
|
||||
value = "/"
|
||||
}
|
||||
result = append(result, value)
|
||||
if pathType == "PathPrefix" && value == "/" {
|
||||
catchAll = true
|
||||
}
|
||||
}
|
||||
return result, catchAll, nil
|
||||
}
|
||||
|
||||
// warnMissingCatchAllPath warns when a Gateway API route does not match every
|
||||
// path for the hosts it routes. Unlike an Ingress, a route declares its hosts
|
||||
// once for all of its rules, so the rules are checked as a whole.
|
||||
func (k *KubernetesService) warnMissingCatchAllPath(item *unstructured.Unstructured) {
|
||||
rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules")
|
||||
if err != nil {
|
||||
// This is purely to warn users
|
||||
// It doesn't affect our ability to extract hosts, so we won't fail the whole operation
|
||||
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from route rules")
|
||||
return
|
||||
}
|
||||
if !found || len(rules) == 0 {
|
||||
return
|
||||
}
|
||||
var paths []string
|
||||
for _, r := range rules {
|
||||
rule, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rulePaths, catchAll, err := k.extractRoutePaths(rule)
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from route rule")
|
||||
continue
|
||||
}
|
||||
if catchAll {
|
||||
return
|
||||
}
|
||||
paths = append(paths, rulePaths...)
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Route does not contain a catch-all path, another route may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this route to ensure auth checks are applied to all paths for this host.")
|
||||
}
|
||||
|
||||
func (k *KubernetesService) extractIngressHosts(item *unstructured.Unstructured) ([]string, error) {
|
||||
rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading spec.rules: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
var hosts []string
|
||||
for _, r := range rules {
|
||||
rule, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if host, ok := rule["host"].(string); ok && host != "" {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
paths, err := k.extractPaths(rule)
|
||||
if err != nil {
|
||||
// This is purely to warn users
|
||||
// It doesn't affect our ability to extract hosts, so we won't fail the whole operation
|
||||
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
|
||||
continue
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(paths, "/") {
|
||||
k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.")
|
||||
}
|
||||
}
|
||||
k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from ingress rules")
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) extractRouteHosts(res watchedResource, item *unstructured.Unstructured) ([]string, error) {
|
||||
hostnames, found, err := unstructured.NestedStringSlice(item.Object, "spec", "hostnames")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading spec.hostnames: %w", err)
|
||||
}
|
||||
if !found {
|
||||
// A route without hostnames inherits the ones of the gateway listeners
|
||||
// it attaches to, which we cannot resolve from the route alone
|
||||
return nil, nil
|
||||
}
|
||||
var hosts []string
|
||||
for _, hostname := range hostnames {
|
||||
if hostname != "" {
|
||||
hosts = append(hosts, hostname)
|
||||
}
|
||||
}
|
||||
if res.httpPaths {
|
||||
k.warnMissingCatchAllPath(item)
|
||||
}
|
||||
k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from route hostnames")
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) extractHosts(res watchedResource, item *unstructured.Unstructured) ([]string, error) {
|
||||
if res.gatewayAPI {
|
||||
return k.extractRouteHosts(res, item)
|
||||
}
|
||||
return k.extractIngressHosts(item)
|
||||
}
|
||||
|
||||
func (k *KubernetesService) updateFromItem(res watchedResource, item *unstructured.Unstructured) {
|
||||
key := resourceKey{
|
||||
resource: res.gvr.Resource,
|
||||
namespace: item.GetNamespace(),
|
||||
name: item.GetName(),
|
||||
}
|
||||
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
k.removeResource(key)
|
||||
return
|
||||
}
|
||||
|
||||
hosts, err := k.extractHosts(res, item)
|
||||
if err != nil {
|
||||
k.removeResource(key)
|
||||
return
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
k.log.App.Warn().Str("api", res.api()).Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in resource, skipping")
|
||||
k.removeResource(key)
|
||||
return
|
||||
}
|
||||
|
||||
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode resource labels, skipping")
|
||||
k.removeResource(key)
|
||||
return
|
||||
}
|
||||
|
||||
var entries []resourceEntry
|
||||
|
||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
||||
|
||||
for name, config := range labels.Apps {
|
||||
if config.Config.Domain != "" {
|
||||
hostname, err := v.SafeHostname(config.Config.Domain)
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name")
|
||||
} else if slices.ContainsFunc(hosts, func(host string) bool {
|
||||
return hostMatches(host, hostname)
|
||||
}) {
|
||||
entries = append(entries, resourceEntry{
|
||||
name: name,
|
||||
app: config,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if slices.ContainsFunc(hosts, func(host string) bool {
|
||||
return hostCoversName(host, name)
|
||||
}) {
|
||||
entries = append(entries, resourceEntry{
|
||||
name: name,
|
||||
app: config,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
k.removeResource(key)
|
||||
return
|
||||
}
|
||||
|
||||
k.addResourceEntries(key, entries)
|
||||
}
|
||||
|
||||
func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{})
|
||||
list, err := k.client.Resource(res.gvr).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list resources for resync")
|
||||
k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Failed to list resources for resync")
|
||||
return err
|
||||
}
|
||||
for i := range list.Items {
|
||||
k.updateFromItem(&list.Items[i])
|
||||
k.updateFromItem(res, &list.Items[i])
|
||||
}
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resync complete")
|
||||
k.log.App.Debug().Str("api", res.api()).Int("count", len(list.Items)).Msg("Resync complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// runWatcher drains events from an active watcher until it closes or the context is done.
|
||||
// Returns true if the caller should restart the watcher, false if it should exit.
|
||||
func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker) bool {
|
||||
func (k *KubernetesService) runWatcher(res watchedResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool {
|
||||
for {
|
||||
select {
|
||||
case <-k.ctx.Done():
|
||||
case <-ctx.Done():
|
||||
w.Stop()
|
||||
return false
|
||||
case event, ok := <-w.ResultChan():
|
||||
if !ok {
|
||||
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting watcher")
|
||||
k.log.App.Warn().Str("api", res.api()).Msg("Watcher channel closed, restarting watcher")
|
||||
w.Stop()
|
||||
time.Sleep(5 * time.Second)
|
||||
return true
|
||||
}
|
||||
item, ok := event.Object.(*unstructured.Unstructured)
|
||||
if !ok {
|
||||
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Received unexpected event object, skipping")
|
||||
k.log.App.Warn().Str("api", res.api()).Msg("Received unexpected event object, skipping")
|
||||
continue
|
||||
}
|
||||
switch event.Type {
|
||||
case watch.Added, watch.Modified:
|
||||
k.updateFromItem(item)
|
||||
k.updateFromItem(res, item)
|
||||
case watch.Deleted:
|
||||
k.removeResource(item.GetNamespace(), item.GetName())
|
||||
k.removeResource(resourceKey{
|
||||
resource: res.gvr.Resource,
|
||||
namespace: item.GetNamespace(),
|
||||
name: item.GetName(),
|
||||
})
|
||||
}
|
||||
case <-resyncTicker.C:
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed during watcher run")
|
||||
if err := k.resyncGVR(res, ctx); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Periodic resync failed during watcher run")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
|
||||
func (k *KubernetesService) watchGVR(res watchedResource, ctx context.Context) {
|
||||
resyncTicker := time.NewTicker(5 * time.Minute)
|
||||
defer resyncTicker.Stop()
|
||||
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry")
|
||||
if err := k.resyncGVR(res, ctx); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Initial resync failed, will retry")
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-k.ctx.Done():
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Shutting down kubernetes watcher")
|
||||
case <-ctx.Done():
|
||||
k.log.App.Debug().Str("api", res.api()).Msg("Shutting down kubernetes watcher")
|
||||
return
|
||||
case <-resyncTicker.C:
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry")
|
||||
if err := k.resyncGVR(res, ctx); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Periodic resync failed, will retry")
|
||||
}
|
||||
default:
|
||||
ctx, cancel := context.WithCancel(k.ctx)
|
||||
watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{})
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
watcher, err := k.client.Resource(res.gvr).Watch(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher, will retry")
|
||||
k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Failed to start watcher, will retry")
|
||||
cancel()
|
||||
time.Sleep(10 * time.Second)
|
||||
continue
|
||||
}
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started successfully")
|
||||
if !k.runWatcher(gvr, watcher, resyncTicker) {
|
||||
k.log.App.Debug().Str("api", res.api()).Msg("Watcher started successfully")
|
||||
if !k.runWatcher(res, watcher, resyncTicker, ctx) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
@@ -305,25 +533,13 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) {
|
||||
if !k.started {
|
||||
k.log.App.Debug().Str("domain", appDomain).Msg("Kubernetes label provider not started, skipping")
|
||||
return nil, nil
|
||||
func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error {
|
||||
if !k.connected {
|
||||
k.log.App.Debug().Msg("Kubernetes label provider not started, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
// First check cache
|
||||
app := k.getByDomain(appDomain)
|
||||
if app != nil {
|
||||
k.log.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain")
|
||||
return app, nil
|
||||
}
|
||||
appName := strings.SplitN(appDomain, ".", 2)[0]
|
||||
app = k.getByAppName(appName)
|
||||
if app != nil {
|
||||
k.log.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name")
|
||||
return app, nil
|
||||
}
|
||||
k.getEntry(locator)
|
||||
|
||||
k.log.App.Debug().Str("domain", appDomain).Msg("No labels found for domain")
|
||||
return nil, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,45 +9,55 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v5"
|
||||
ldapgo "github.com/go-ldap/ldap/v3"
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
type LdapService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
context context.Context
|
||||
log *logger.Logger
|
||||
ctx context.Context
|
||||
config *model.Config
|
||||
|
||||
conn *ldapgo.Conn
|
||||
mutex sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
conn *ldapgo.Conn
|
||||
mutex sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
bindPw string
|
||||
}
|
||||
|
||||
func NewLdapService(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
) (*LdapService, error) {
|
||||
if config.LDAP.Address == "" {
|
||||
type LdapServiceInput struct {
|
||||
dig.In
|
||||
|
||||
Log *logger.Logger
|
||||
Config *model.Config
|
||||
Ding *ding.Ding
|
||||
Ctx context.Context
|
||||
}
|
||||
|
||||
func NewLdapService(i LdapServiceInput) (*LdapService, error) {
|
||||
if i.Config.LDAP.Address == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ldap := &LdapService{
|
||||
log: log,
|
||||
config: config,
|
||||
context: ctx,
|
||||
log: i.Log,
|
||||
config: i.Config,
|
||||
ctx: i.Ctx,
|
||||
}
|
||||
|
||||
ldap.bindPw = utils.GetSecret(i.Config.LDAP.BindPassword, i.Config.LDAP.BindPasswordFile)
|
||||
|
||||
// Check whether authentication with client certificate is possible
|
||||
if config.LDAP.AuthCert != "" && config.LDAP.AuthKey != "" {
|
||||
cert, err := tls.LoadX509KeyPair(config.LDAP.AuthCert, config.LDAP.AuthKey)
|
||||
if i.Config.LDAP.AuthCert != "" && i.Config.LDAP.AuthKey != "" {
|
||||
cert, err := tls.LoadX509KeyPair(i.Config.LDAP.AuthCert, i.Config.LDAP.AuthKey)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LDAP with mTLS authentication: %w", err)
|
||||
}
|
||||
|
||||
log.App.Info().Msg("LDAP mTLS authentication configured successfully")
|
||||
i.Log.App.Info().Msg("LDAP mTLS authentication configured successfully")
|
||||
|
||||
ldap.cert = &cert
|
||||
|
||||
@@ -66,10 +76,12 @@ func NewLdapService(
|
||||
_, err := ldap.connect()
|
||||
|
||||
if err != nil {
|
||||
// 3s + 4.5s (3x1.5) = ~6.75-8.25s total wait time before giving up
|
||||
err = ldap.reconnect(3 * time.Second)
|
||||
return nil, fmt.Errorf("failed to connect to ldap server: %w", err)
|
||||
}
|
||||
|
||||
wg.Go(func() {
|
||||
i.Ding.Go(func(ctx context.Context) {
|
||||
ldap.log.App.Debug().Msg("Starting LDAP connection heartbeat routine")
|
||||
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
@@ -81,18 +93,18 @@ func NewLdapService(
|
||||
err := ldap.heartbeat()
|
||||
if err != nil {
|
||||
ldap.log.App.Warn().Err(err).Msg("LDAP connection heartbeat failed, attempting to reconnect")
|
||||
if reconnectErr := ldap.reconnect(); reconnectErr != nil {
|
||||
if reconnectErr := ldap.reconnect(1 * time.Second); reconnectErr != nil {
|
||||
ldap.log.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
|
||||
continue
|
||||
}
|
||||
ldap.log.App.Info().Msg("Successfully reconnected to LDAP server")
|
||||
}
|
||||
case <-ldap.context.Done():
|
||||
case <-ctx.Done():
|
||||
ldap.log.App.Debug().Msg("LDAP service context cancelled, stopping heartbeat")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}, ding.RingMajor)
|
||||
|
||||
return ldap, nil
|
||||
}
|
||||
@@ -134,7 +146,7 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
||||
return ldap.conn, nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) GetUserInfo(username string) (dn string, email string, err error) {
|
||||
func (ldap *LdapService) GetUserInfo(username string) (dn string, email string, cn string, err error) {
|
||||
escapedUsername := ldapgo.EscapeFilter(username)
|
||||
filter := fmt.Sprintf(ldap.config.LDAP.SearchFilter, escapedUsername)
|
||||
|
||||
@@ -142,7 +154,7 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string,
|
||||
ldap.config.LDAP.BaseDN,
|
||||
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
||||
filter,
|
||||
[]string{"dn", "mail"},
|
||||
[]string{"dn", "mail", "cn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -151,15 +163,35 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string,
|
||||
|
||||
searchResult, err := ldap.conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if len(searchResult.Entries) != 1 {
|
||||
return "", "", fmt.Errorf("multiple or no entries found for user %s", username)
|
||||
return "", "", "", fmt.Errorf("multiple or no entries found for user %s", username)
|
||||
}
|
||||
|
||||
entry := searchResult.Entries[0]
|
||||
return entry.DN, entry.GetAttributeValue("mail"), nil
|
||||
return entry.DN, entry.GetAttributeValue("mail"), entry.GetAttributeValue("cn"), nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) GetUserCount() (int, error) {
|
||||
searchRequest := ldapgo.NewSearchRequest(
|
||||
ldap.config.LDAP.BaseDN,
|
||||
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
||||
"(objectClass=person)",
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
ldap.mutex.Lock()
|
||||
defer ldap.mutex.Unlock()
|
||||
|
||||
searchResult, err := ldap.conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return len(searchResult.Entries), nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) {
|
||||
@@ -214,7 +246,13 @@ func (ldap *LdapService) BindService(rebind bool) error {
|
||||
if ldap.cert != nil {
|
||||
return ldap.conn.ExternalBind()
|
||||
}
|
||||
return ldap.conn.Bind(ldap.config.LDAP.BindDN, ldap.config.LDAP.BindPassword)
|
||||
|
||||
// attempt unauthenticated/anonymous bind if both BindDN and bindpw are unset
|
||||
if ldap.config.LDAP.BindDN == "" && ldap.bindPw == "" {
|
||||
return ldap.conn.UnauthenticatedBind("")
|
||||
}
|
||||
|
||||
return ldap.conn.Bind(ldap.config.LDAP.BindDN, ldap.bindPw)
|
||||
}
|
||||
|
||||
func (ldap *LdapService) Bind(userDN string, password string) error {
|
||||
@@ -249,17 +287,19 @@ func (ldap *LdapService) heartbeat() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) reconnect() error {
|
||||
func (ldap *LdapService) reconnect(interval time.Duration) error {
|
||||
ldap.log.App.Info().Msg("Attempting to reconnect to LDAP server")
|
||||
|
||||
exp := backoff.NewExponentialBackOff()
|
||||
exp.InitialInterval = 500 * time.Millisecond
|
||||
exp.InitialInterval = interval
|
||||
exp.RandomizationFactor = 0.1
|
||||
exp.Multiplier = 1.5
|
||||
exp.Reset()
|
||||
|
||||
operation := func() (*ldapgo.Conn, error) {
|
||||
ldap.conn.Close()
|
||||
if ldap.conn != nil {
|
||||
ldap.conn.Close()
|
||||
}
|
||||
conn, err := ldap.connect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -267,7 +307,7 @@ func (ldap *LdapService) reconnect() error {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
_, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
|
||||
_, err := backoff.Retry(ldap.ctx, operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -5,25 +5,28 @@ import (
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"go.uber.org/dig"
|
||||
|
||||
"slices"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type OAuthServiceImpl interface {
|
||||
type IOAuthService interface {
|
||||
Name() string
|
||||
ID() string
|
||||
NewRandom() string
|
||||
GetAuthURL(state string, verifier string) string
|
||||
GetToken(code string, verifier string) (*oauth2.Token, error)
|
||||
GetAuthURL(state, verifier string) string
|
||||
GetToken(code, verifier string) (*oauth2.Token, error)
|
||||
GetUserinfo(token *oauth2.Token) (*model.Claims, error)
|
||||
GetConfig() model.OAuthServiceConfig
|
||||
UpdateConfig(config model.OAuthServiceConfig)
|
||||
}
|
||||
|
||||
type OAuthBrokerService struct {
|
||||
log *logger.Logger
|
||||
|
||||
services map[string]OAuthServiceImpl
|
||||
services map[string]IOAuthService
|
||||
configs map[string]model.OAuthServiceConfig
|
||||
}
|
||||
|
||||
@@ -32,23 +35,27 @@ var presets = map[string]func(config model.OAuthServiceConfig, ctx context.Conte
|
||||
"google": newGoogleOAuthService,
|
||||
}
|
||||
|
||||
func NewOAuthBrokerService(
|
||||
log *logger.Logger,
|
||||
configs map[string]model.OAuthServiceConfig,
|
||||
ctx context.Context,
|
||||
) *OAuthBrokerService {
|
||||
type OAuthBrokerServiceInput struct {
|
||||
dig.In
|
||||
|
||||
Log *logger.Logger
|
||||
Runtime *model.RuntimeConfig
|
||||
Ctx context.Context
|
||||
}
|
||||
|
||||
func NewOAuthBrokerService(i OAuthBrokerServiceInput) *OAuthBrokerService {
|
||||
service := &OAuthBrokerService{
|
||||
log: log,
|
||||
services: make(map[string]OAuthServiceImpl),
|
||||
configs: configs,
|
||||
log: i.Log,
|
||||
services: make(map[string]IOAuthService),
|
||||
configs: i.Runtime.OAuthProviders,
|
||||
}
|
||||
|
||||
for name, cfg := range configs {
|
||||
for name, cfg := range service.configs {
|
||||
if presetFunc, exists := presets[name]; exists {
|
||||
service.services[name] = presetFunc(cfg, ctx)
|
||||
service.services[name] = presetFunc(cfg, i.Ctx)
|
||||
service.log.App.Debug().Str("service", name).Msg("Loaded OAuth service from preset")
|
||||
} else {
|
||||
service.services[name] = NewOAuthService(cfg, name, ctx)
|
||||
service.services[name] = NewOAuthService(cfg, name, i.Ctx)
|
||||
service.log.App.Debug().Str("service", name).Msg("Loaded OAuth service from custom config")
|
||||
}
|
||||
}
|
||||
@@ -65,7 +72,7 @@ func (broker *OAuthBrokerService) GetConfiguredServices() []string {
|
||||
return services
|
||||
}
|
||||
|
||||
func (broker *OAuthBrokerService) GetService(name string) (OAuthServiceImpl, bool) {
|
||||
func (broker *OAuthBrokerService) GetService(name string) (IOAuthService, bool) {
|
||||
service, exists := broker.services[name]
|
||||
return service, exists
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -17,27 +15,27 @@ type GithubEmailResponse []struct {
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
type GithubUserInfoResponse struct {
|
||||
type GithubUserinfoResponse struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
func defaultExtractor(client *http.Client, url string) (*model.Claims, error) {
|
||||
return simpleReq[model.Claims](client, url, nil)
|
||||
func defaultExtractor(client *http.Client, ctx context.Context, url string) (*model.Claims, error) {
|
||||
return simpleReq[model.Claims](client, ctx, url, nil)
|
||||
}
|
||||
|
||||
func githubExtractor(client *http.Client, _ string) (*model.Claims, error) {
|
||||
func githubExtractor(client *http.Client, ctx context.Context, _ string) (*model.Claims, error) {
|
||||
var user model.Claims
|
||||
|
||||
userInfo, err := simpleReq[GithubUserInfoResponse](client, "https://api.github.com/user", map[string]string{
|
||||
userInfo, err := simpleReq[GithubUserinfoResponse](client, ctx, "https://api.github.com/user", map[string]string{
|
||||
"accept": "application/vnd.github+json",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userEmails, err := simpleReq[GithubEmailResponse](client, "https://api.github.com/user/emails", map[string]string{
|
||||
userEmails, err := simpleReq[GithubEmailResponse](client, ctx, "https://api.github.com/user/emails", map[string]string{
|
||||
"accept": "application/vnd.github+json",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -75,38 +73,3 @@ func githubExtractor(client *http.Client, _ string) (*model.Claims, error) {
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func simpleReq[T any](client *http.Client, url string, headers map[string]string) (*T, error) {
|
||||
var decodedRes T
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("request failed with status: %s", res.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &decodedRes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &decodedRes, nil
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type UserinfoExtractor func(client *http.Client, url string) (*model.Claims, error)
|
||||
type OAuthUserinfoExtractor func(client *http.Client, ctx context.Context, url string) (*model.Claims, error)
|
||||
|
||||
type OAuthService struct {
|
||||
serviceCfg model.OAuthServiceConfig
|
||||
config *oauth2.Config
|
||||
ctx context.Context
|
||||
userinfoExtractor UserinfoExtractor
|
||||
userinfoExtractor OAuthUserinfoExtractor
|
||||
id string
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: config.Insecure,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
@@ -50,7 +51,7 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OAuthService) WithUserinfoExtractor(extractor UserinfoExtractor) *OAuthService {
|
||||
func (s *OAuthService) WithUserinfoExtractor(extractor OAuthUserinfoExtractor) *OAuthService {
|
||||
s.userinfoExtractor = extractor
|
||||
return s
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func (s *OAuthService) NewRandom() string {
|
||||
return random
|
||||
}
|
||||
|
||||
func (s *OAuthService) GetAuthURL(state string, verifier string) string {
|
||||
func (s *OAuthService) GetAuthURL(state, verifier string) string {
|
||||
return s.config.AuthCodeURL(state, oauth2.AccessTypeOnline, oauth2.S256ChallengeOption(verifier))
|
||||
}
|
||||
|
||||
@@ -80,5 +81,19 @@ func (s *OAuthService) GetToken(code string, verifier string) (*oauth2.Token, er
|
||||
|
||||
func (s *OAuthService) GetUserinfo(token *oauth2.Token) (*model.Claims, error) {
|
||||
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
|
||||
return s.userinfoExtractor(client, s.serviceCfg.UserinfoURL)
|
||||
return s.userinfoExtractor(client, s.ctx, s.serviceCfg.UserinfoURL)
|
||||
}
|
||||
|
||||
func (s *OAuthService) GetConfig() model.OAuthServiceConfig {
|
||||
return s.serviceCfg
|
||||
}
|
||||
|
||||
func (s *OAuthService) UpdateConfig(config model.OAuthServiceConfig) {
|
||||
s.serviceCfg = config
|
||||
s.config.ClientID = config.ClientID
|
||||
s.config.ClientSecret = config.ClientSecret
|
||||
s.config.Scopes = config.Scopes
|
||||
s.config.Endpoint.AuthURL = config.AuthURL
|
||||
s.config.Endpoint.TokenURL = config.TokenURL
|
||||
s.config.RedirectURL = config.RedirectURL
|
||||
}
|
||||
|
||||
+462
-224
@@ -7,7 +7,6 @@ import (
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
@@ -15,18 +14,22 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/cache"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -43,12 +46,26 @@ var (
|
||||
ErrInvalidClient = errors.New("invalid_client")
|
||||
)
|
||||
|
||||
type OIDCPrompt string
|
||||
|
||||
const (
|
||||
OIDCPromptLogin OIDCPrompt = "login"
|
||||
OIDCPromptNone OIDCPrompt = "none"
|
||||
)
|
||||
|
||||
var SupportedPrompts = []string{string(OIDCPromptLogin), string(OIDCPromptNone)}
|
||||
|
||||
// This is not spec-compliant, the ID token SHOULD NOT contain user info claims but,
|
||||
// it has became a "standard" and apps are looking for the claims in the ID tokens
|
||||
// instead of calling the userinfo endpoint, so we include them in the ID token as well
|
||||
// for better compatibility with existing apps
|
||||
type ClaimSet struct {
|
||||
Iss string `json:"iss"`
|
||||
Aud string `json:"aud"`
|
||||
Sub string `json:"sub"`
|
||||
Iat int64 `json:"iat"`
|
||||
Exp int64 `json:"exp"`
|
||||
AuthTime int64 `json:"auth_time,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
@@ -68,6 +85,8 @@ type ClaimSet struct {
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
}
|
||||
|
||||
// We use this struct as both a response struct and a struct to store userinfo
|
||||
// in the database
|
||||
type UserinfoResponse struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -102,43 +121,73 @@ type TokenResponse struct {
|
||||
}
|
||||
|
||||
type AuthorizeRequest struct {
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
ResponseType string `json:"response_type" binding:"required"`
|
||||
ClientID string `json:"client_id" binding:"required"`
|
||||
RedirectURI string `json:"redirect_uri" binding:"required"`
|
||||
State string `json:"state"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
CodeChallengeMethod string `json:"code_challenge_method"`
|
||||
Scope string `form:"scope" json:"scope" url:"scope"`
|
||||
ResponseType string `form:"response_type" json:"response_type" url:"response_type"`
|
||||
ClientID string `form:"client_id" json:"client_id" url:"client_id"`
|
||||
RedirectURI string `form:"redirect_uri" json:"redirect_uri" url:"redirect_uri"`
|
||||
State string `form:"state" json:"state" url:"state"`
|
||||
Nonce string `form:"nonce" json:"nonce" url:"nonce"`
|
||||
CodeChallenge string `form:"code_challenge" json:"code_challenge" url:"code_challenge"`
|
||||
CodeChallengeMethod string `form:"code_challenge_method" json:"code_challenge_method" url:"code_challenge_method"`
|
||||
Prompt string `form:"prompt" json:"prompt" url:"prompt"`
|
||||
MaxAge string `form:"max_age" json:"max_age" url:"max_age"`
|
||||
}
|
||||
|
||||
type AuthorizeCodeEntry struct {
|
||||
CodeHash string
|
||||
Scope string
|
||||
RedirectURI string
|
||||
ClientID string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
Userinfo UserinfoResponse
|
||||
AuthTime int64
|
||||
}
|
||||
|
||||
type UsedCodeEntry struct {
|
||||
Sub string
|
||||
}
|
||||
|
||||
type OIDCService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
queries *repository.Queries
|
||||
context context.Context
|
||||
config *model.Config
|
||||
runtime *model.RuntimeConfig
|
||||
queries repository.Store
|
||||
|
||||
clients map[string]model.OIDCClientConfig
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
issuer string
|
||||
|
||||
caches struct {
|
||||
code *cache.CacheStore[AuthorizeCodeEntry]
|
||||
usedCode *cache.CacheStore[UsedCodeEntry]
|
||||
authorize *cache.CacheStore[AuthorizeRequest]
|
||||
}
|
||||
|
||||
mus struct {
|
||||
consent sync.RWMutex
|
||||
}
|
||||
}
|
||||
|
||||
func NewOIDCService(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
runtime model.RuntimeConfig,
|
||||
queries *repository.Queries,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup) (*OIDCService, error) {
|
||||
type OIDCServiceInput struct {
|
||||
dig.In
|
||||
|
||||
Log *logger.Logger
|
||||
Config *model.Config
|
||||
Runtime *model.RuntimeConfig
|
||||
Queries repository.Store
|
||||
Ding *ding.Ding
|
||||
}
|
||||
|
||||
func NewOIDCService(i OIDCServiceInput) (*OIDCService, error) {
|
||||
// If not configured, skip init
|
||||
if len(runtime.OIDCClients) == 0 {
|
||||
if len(i.Config.OIDC.Clients) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Ensure issuer is https
|
||||
uissuer, err := url.Parse(runtime.AppURL)
|
||||
uissuer, err := url.Parse(i.Runtime.AppURL)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse app url: %w", err)
|
||||
@@ -151,14 +200,14 @@ func NewOIDCService(
|
||||
issuer := fmt.Sprintf("%s://%s", uissuer.Scheme, uissuer.Host)
|
||||
|
||||
// Create/load private and public keys
|
||||
if strings.TrimSpace(config.OIDC.PrivateKeyPath) == "" ||
|
||||
strings.TrimSpace(config.OIDC.PublicKeyPath) == "" {
|
||||
if strings.TrimSpace(i.Config.OIDC.PrivateKeyPath) == "" ||
|
||||
strings.TrimSpace(i.Config.OIDC.PublicKeyPath) == "" {
|
||||
return nil, errors.New("private key path and public key path are required")
|
||||
}
|
||||
|
||||
var privateKey *rsa.PrivateKey
|
||||
|
||||
fprivateKey, err := os.ReadFile(config.OIDC.PrivateKeyPath)
|
||||
fprivateKey, err := os.ReadFile(i.Config.OIDC.PrivateKeyPath)
|
||||
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
@@ -177,8 +226,12 @@ func NewOIDCService(
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: der,
|
||||
})
|
||||
log.App.Trace().Str("type", "RSA PRIVATE KEY").Msg("Generated private RSA key")
|
||||
err = os.WriteFile(config.OIDC.PrivateKeyPath, encoded, 0600)
|
||||
i.Log.App.Trace().Str("type", "RSA PRIVATE KEY").Msg("Generated private RSA key")
|
||||
err := os.MkdirAll(filepath.Dir(i.Config.OIDC.PrivateKeyPath), 0700)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory for private key: %w", err)
|
||||
}
|
||||
err = os.WriteFile(i.Config.OIDC.PrivateKeyPath, encoded, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write private key to file: %w", err)
|
||||
}
|
||||
@@ -187,7 +240,7 @@ func NewOIDCService(
|
||||
if block == nil {
|
||||
return nil, errors.New("failed to decode private key")
|
||||
}
|
||||
log.App.Trace().Str("type", block.Type).Msg("Loaded private key")
|
||||
i.Log.App.Trace().Str("type", block.Type).Msg("Loaded private key")
|
||||
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
@@ -196,7 +249,7 @@ func NewOIDCService(
|
||||
|
||||
var publicKey crypto.PublicKey
|
||||
|
||||
fpublicKey, err := os.ReadFile(config.OIDC.PublicKeyPath)
|
||||
fpublicKey, err := os.ReadFile(i.Config.OIDC.PublicKeyPath)
|
||||
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("failed to read public key: %w", err)
|
||||
@@ -212,8 +265,12 @@ func NewOIDCService(
|
||||
Type: "RSA PUBLIC KEY",
|
||||
Bytes: der,
|
||||
})
|
||||
log.App.Trace().Str("type", "RSA PUBLIC KEY").Msg("Generated public RSA key")
|
||||
err = os.WriteFile(config.OIDC.PublicKeyPath, encoded, 0644)
|
||||
i.Log.App.Trace().Str("type", "RSA PUBLIC KEY").Msg("Generated public RSA key")
|
||||
err := os.MkdirAll(filepath.Dir(i.Config.OIDC.PublicKeyPath), 0700)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory for public key: %w", err)
|
||||
}
|
||||
err = os.WriteFile(i.Config.OIDC.PublicKeyPath, encoded, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -222,7 +279,7 @@ func NewOIDCService(
|
||||
if block == nil {
|
||||
return nil, errors.New("failed to decode public key")
|
||||
}
|
||||
log.App.Trace().Str("type", block.Type).Msg("Loaded public key")
|
||||
i.Log.App.Trace().Str("type", block.Type).Msg("Loaded public key")
|
||||
switch block.Type {
|
||||
case "RSA PUBLIC KEY":
|
||||
publicKey, err = x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
@@ -252,7 +309,7 @@ func NewOIDCService(
|
||||
// We will reorganize the client into a map with the client ID as the key
|
||||
clients := make(map[string]model.OIDCClientConfig)
|
||||
|
||||
for id, client := range config.OIDC.Clients {
|
||||
for id, client := range i.Config.OIDC.Clients {
|
||||
client.ID = id
|
||||
if client.Name == "" {
|
||||
client.Name = utils.Capitalize(client.ID)
|
||||
@@ -268,16 +325,15 @@ func NewOIDCService(
|
||||
}
|
||||
client.ClientSecretFile = ""
|
||||
clients[id] = client
|
||||
log.App.Debug().Str("clientId", client.ClientID).Msg("Loaded OIDC client configuration")
|
||||
i.Log.App.Debug().Str("clientId", client.ClientID).Msg("Loaded OIDC client configuration")
|
||||
}
|
||||
|
||||
// Initialize the service
|
||||
service := &OIDCService{
|
||||
log: log,
|
||||
config: config,
|
||||
runtime: runtime,
|
||||
queries: queries,
|
||||
context: ctx,
|
||||
log: i.Log,
|
||||
config: i.Config,
|
||||
runtime: i.Runtime,
|
||||
queries: i.Queries,
|
||||
|
||||
clients: clients,
|
||||
privateKey: privateKey,
|
||||
@@ -285,8 +341,39 @@ func NewOIDCService(
|
||||
issuer: issuer,
|
||||
}
|
||||
|
||||
// Remove consents for clients that are no longer configured
|
||||
if err := service.reconcileOIDCConsents(context.Background()); err != nil {
|
||||
i.Log.App.Warn().Err(err).Msg("Failed to reconcile OIDC consents")
|
||||
}
|
||||
|
||||
// Start cleanup routine
|
||||
wg.Go(service.cleanupRoutine)
|
||||
i.Ding.Go(service.cleanupRoutine, ding.RingMinor)
|
||||
|
||||
// Create caches
|
||||
codeCache := cache.NewCacheStore[AuthorizeCodeEntry](256)
|
||||
usedCode := cache.NewCacheStore[UsedCodeEntry](256)
|
||||
authorize := cache.NewCacheStore[AuthorizeRequest](256)
|
||||
|
||||
service.caches.code = codeCache
|
||||
service.caches.usedCode = usedCode
|
||||
service.caches.authorize = authorize
|
||||
|
||||
// Start cache cleanup routine
|
||||
i.Ding.Go(func(ctx context.Context) {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.caches.code.Sweep()
|
||||
service.caches.usedCode.Sweep()
|
||||
service.caches.authorize.Sweep()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}, ding.RingMinor)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
@@ -349,19 +436,18 @@ func (service *OIDCService) filterScopes(scopes []string) []string {
|
||||
})
|
||||
}
|
||||
|
||||
func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, req AuthorizeRequest) error {
|
||||
// Fixed 10 minutes
|
||||
expiresAt := time.Now().Add(time.Minute * time.Duration(10)).Unix()
|
||||
func (service *OIDCService) CreateCode(req AuthorizeRequest, userContext model.UserContext) string {
|
||||
code := utils.GenerateString(32)
|
||||
sub := service.CreateSub(userContext, req.ClientID)
|
||||
|
||||
entry := repository.CreateOidcCodeParams{
|
||||
Sub: sub,
|
||||
CodeHash: service.Hash(code),
|
||||
// Here it's safe to split and trust the output since, we validated the scopes before
|
||||
Scope: strings.Join(service.filterScopes(strings.Split(req.Scope, " ")), ","),
|
||||
entry := AuthorizeCodeEntry{
|
||||
CodeHash: service.Hash(code),
|
||||
Scope: strings.Join(service.filterScopes(strings.Split(req.Scope, " ")), " "),
|
||||
RedirectURI: req.RedirectURI,
|
||||
ClientID: req.ClientID,
|
||||
ExpiresAt: expiresAt,
|
||||
Nonce: req.Nonce,
|
||||
Userinfo: service.userinfoFromContext(userContext, sub),
|
||||
AuthTime: userContext.AuthTime,
|
||||
}
|
||||
|
||||
if req.CodeChallenge != "" {
|
||||
@@ -373,14 +459,14 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the code into the database
|
||||
_, err := service.queries.CreateOidcCode(c, entry)
|
||||
// Store the code in the cache
|
||||
service.caches.code.Set(entry.CodeHash, entry, 1*time.Minute)
|
||||
|
||||
return err
|
||||
return code
|
||||
}
|
||||
|
||||
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext model.UserContext, req AuthorizeRequest) error {
|
||||
userInfoParams := repository.CreateOidcUserInfoParams{
|
||||
func (service *OIDCService) userinfoFromContext(userContext model.UserContext, sub string) UserinfoResponse {
|
||||
userInfo := UserinfoResponse{
|
||||
Sub: sub,
|
||||
Name: userContext.GetName(),
|
||||
Email: userContext.GetEmail(),
|
||||
@@ -389,37 +475,31 @@ func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContex
|
||||
}
|
||||
|
||||
if userContext.IsLocal() {
|
||||
addressJSON, err := json.Marshal(userContext.Local.Attributes.Address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userInfoParams.GivenName = userContext.Local.Attributes.GivenName
|
||||
userInfoParams.FamilyName = userContext.Local.Attributes.FamilyName
|
||||
userInfoParams.MiddleName = userContext.Local.Attributes.MiddleName
|
||||
userInfoParams.Nickname = userContext.Local.Attributes.Nickname
|
||||
userInfoParams.Profile = userContext.Local.Attributes.Profile
|
||||
userInfoParams.Picture = userContext.Local.Attributes.Picture
|
||||
userInfoParams.Website = userContext.Local.Attributes.Website
|
||||
userInfoParams.Gender = userContext.Local.Attributes.Gender
|
||||
userInfoParams.Birthdate = userContext.Local.Attributes.Birthdate
|
||||
userInfoParams.Zoneinfo = userContext.Local.Attributes.Zoneinfo
|
||||
userInfoParams.Locale = userContext.Local.Attributes.Locale
|
||||
userInfoParams.PhoneNumber = userContext.Local.Attributes.PhoneNumber
|
||||
userInfoParams.Address = string(addressJSON)
|
||||
userInfo.GivenName = userContext.Local.Attributes.GivenName
|
||||
userInfo.FamilyName = userContext.Local.Attributes.FamilyName
|
||||
userInfo.MiddleName = userContext.Local.Attributes.MiddleName
|
||||
userInfo.Nickname = userContext.Local.Attributes.Nickname
|
||||
userInfo.Profile = userContext.Local.Attributes.Profile
|
||||
userInfo.Picture = userContext.Local.Attributes.Picture
|
||||
userInfo.Website = userContext.Local.Attributes.Website
|
||||
userInfo.Gender = userContext.Local.Attributes.Gender
|
||||
userInfo.Birthdate = userContext.Local.Attributes.Birthdate
|
||||
userInfo.Zoneinfo = userContext.Local.Attributes.Zoneinfo
|
||||
userInfo.Locale = userContext.Local.Attributes.Locale
|
||||
userInfo.PhoneNumber = userContext.Local.Attributes.PhoneNumber
|
||||
userInfo.Address = &userContext.Local.Attributes.Address
|
||||
}
|
||||
|
||||
// Tinyauth will pass through the groups it got from an LDAP or an OIDC server
|
||||
if userContext.IsLDAP() {
|
||||
userInfoParams.Groups = strings.Join(userContext.LDAP.Groups, ",")
|
||||
userInfo.Groups = userContext.LDAP.Groups
|
||||
}
|
||||
|
||||
if userContext.IsOAuth() {
|
||||
userInfoParams.Groups = strings.Join(userContext.OAuth.Groups, ",")
|
||||
userInfo.Groups = userContext.OAuth.Groups
|
||||
}
|
||||
|
||||
_, err := service.queries.CreateOidcUserInfo(c, userInfoParams)
|
||||
|
||||
return err
|
||||
return userInfo
|
||||
}
|
||||
|
||||
func (service *OIDCService) ValidateGrantType(grantType string) error {
|
||||
@@ -430,36 +510,34 @@ func (service *OIDCService) ValidateGrantType(grantType string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string, clientId string) (repository.OidcCode, error) {
|
||||
oidcCode, err := service.queries.GetOidcCode(c, codeHash)
|
||||
func (service *OIDCService) GetCodeEntry(codeHash string, clientId string) (*AuthorizeCodeEntry, bool) {
|
||||
var entry AuthorizeCodeEntry
|
||||
var ok bool
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return repository.OidcCode{}, ErrCodeNotFound
|
||||
service.caches.code.WithLock(func(actions cache.CacheStoreActions[AuthorizeCodeEntry]) {
|
||||
entry, ok = actions.Get(codeHash)
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
return repository.OidcCode{}, err
|
||||
|
||||
if entry.ClientID != clientId {
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
|
||||
// Since the code can only be used once, we delete it from the cache after retrieving it
|
||||
actions.Delete(codeHash)
|
||||
})
|
||||
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if time.Now().Unix() > oidcCode.ExpiresAt {
|
||||
err = service.queries.DeleteOidcCode(c, codeHash)
|
||||
if err != nil {
|
||||
return repository.OidcCode{}, err
|
||||
}
|
||||
err = service.DeleteUserinfo(c, oidcCode.Sub)
|
||||
if err != nil {
|
||||
return repository.OidcCode{}, err
|
||||
}
|
||||
return repository.OidcCode{}, ErrCodeExpired
|
||||
}
|
||||
|
||||
if oidcCode.ClientID != clientId {
|
||||
return repository.OidcCode{}, ErrInvalidClient
|
||||
}
|
||||
|
||||
return oidcCode, nil
|
||||
return &entry, true
|
||||
}
|
||||
|
||||
func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) {
|
||||
func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user UserinfoResponse, scope string, nonce string, authTime *int64) (string, error) {
|
||||
createdAt := time.Now().Unix()
|
||||
expiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix()
|
||||
|
||||
@@ -504,6 +582,10 @@ func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user
|
||||
Nonce: nonce,
|
||||
}
|
||||
|
||||
if authTime != nil {
|
||||
claims.AuthTime = *authTime
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(claims)
|
||||
|
||||
if err != nil {
|
||||
@@ -525,17 +607,11 @@ func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OIDCClientConfig, codeEntry repository.OidcCode) (TokenResponse, error) {
|
||||
user, err := service.GetUserinfo(c, codeEntry.Sub)
|
||||
func (service *OIDCService) GenerateAccessToken(ctx context.Context, client model.OIDCClientConfig, codeEntry AuthorizeCodeEntry, authTime int64) (*TokenResponse, error) {
|
||||
idToken, err := service.generateIDToken(client, codeEntry.Userinfo, codeEntry.Scope, codeEntry.Nonce, &authTime)
|
||||
|
||||
if err != nil {
|
||||
return TokenResponse{}, err
|
||||
}
|
||||
|
||||
idToken, err := service.generateIDToken(client, user, codeEntry.Scope, codeEntry.Nonce)
|
||||
|
||||
if err != nil {
|
||||
return TokenResponse{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessToken := utils.GenerateString(32)
|
||||
@@ -555,56 +631,69 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OID
|
||||
Scope: strings.ReplaceAll(codeEntry.Scope, ",", " "),
|
||||
}
|
||||
|
||||
_, err = service.queries.CreateOidcToken(c, repository.CreateOidcTokenParams{
|
||||
Sub: codeEntry.Sub,
|
||||
var userInfoJson []byte
|
||||
|
||||
userInfoJson, err = json.Marshal(codeEntry.Userinfo)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = service.queries.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{
|
||||
Sub: codeEntry.Userinfo.Sub,
|
||||
AccessTokenHash: service.Hash(accessToken),
|
||||
RefreshTokenHash: service.Hash(refreshToken),
|
||||
ClientID: client.ClientID,
|
||||
Scope: codeEntry.Scope,
|
||||
ClientID: client.ClientID,
|
||||
TokenExpiresAt: tokenExpiresAt,
|
||||
RefreshTokenExpiresAt: refreshTokenExpiresAt,
|
||||
Nonce: codeEntry.Nonce,
|
||||
CodeHash: codeEntry.CodeHash,
|
||||
UserinfoJson: string(userInfoJson),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return TokenResponse{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenResponse, nil
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken string, reqClientId string) (TokenResponse, error) {
|
||||
entry, err := service.queries.GetOidcTokenByRefreshToken(c, service.Hash(refreshToken))
|
||||
func (service *OIDCService) RefreshAccessToken(ctx context.Context, refreshToken string, clientId string) (*TokenResponse, error) {
|
||||
entry, err := service.queries.GetOIDCSessionByRefreshTokenHash(ctx, service.Hash(refreshToken))
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TokenResponse{}, ErrTokenNotFound
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
return TokenResponse{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if entry.RefreshTokenExpiresAt < time.Now().Unix() {
|
||||
return TokenResponse{}, ErrTokenExpired
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
|
||||
// Ensure the client ID in the request matches the client ID in the token
|
||||
if entry.ClientID != reqClientId {
|
||||
return TokenResponse{}, ErrInvalidClient
|
||||
if entry.ClientID != clientId {
|
||||
return nil, ErrInvalidClient
|
||||
}
|
||||
|
||||
user, err := service.GetUserinfo(c, entry.Sub)
|
||||
// we need to unmarshal the userinfo from the database to include it in the new ID token,
|
||||
// since the ID token includes user claims for better compatibility with existing apps
|
||||
var userInfo UserinfoResponse
|
||||
|
||||
err = json.Unmarshal([]byte(entry.UserinfoJson), &userInfo)
|
||||
|
||||
if err != nil {
|
||||
return TokenResponse{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: store auth time in the database so we can include it in the new ID token, for now we omit it
|
||||
idToken, err := service.generateIDToken(model.OIDCClientConfig{
|
||||
ClientID: entry.ClientID,
|
||||
}, user, entry.Scope, entry.Nonce)
|
||||
}, userInfo, entry.Scope, entry.Nonce, nil)
|
||||
|
||||
if err != nil {
|
||||
return TokenResponse{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessToken := utils.GenerateString(32)
|
||||
@@ -622,71 +711,54 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
||||
Scope: strings.ReplaceAll(entry.Scope, ",", " "),
|
||||
}
|
||||
|
||||
_, err = service.queries.UpdateOidcTokenByRefreshToken(c, repository.UpdateOidcTokenByRefreshTokenParams{
|
||||
_, err = service.queries.UpdateOIDCSession(ctx, repository.UpdateOIDCSessionParams{
|
||||
Sub: entry.Sub,
|
||||
AccessTokenHash: service.Hash(accessToken),
|
||||
RefreshTokenHash: service.Hash(newRefreshToken),
|
||||
Scope: entry.Scope,
|
||||
ClientID: entry.ClientID,
|
||||
TokenExpiresAt: tokenExpiresAt,
|
||||
RefreshTokenExpiresAt: refreshTokenExpiresAt,
|
||||
RefreshTokenHash_2: service.Hash(refreshToken), // that's the selector, it's not stored in the db
|
||||
Nonce: entry.Nonce,
|
||||
UserinfoJson: entry.UserinfoJson,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return TokenResponse{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenResponse, nil
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) DeleteCodeEntry(c *gin.Context, codeHash string) error {
|
||||
return service.queries.DeleteOidcCode(c, codeHash)
|
||||
}
|
||||
|
||||
func (service *OIDCService) DeleteUserinfo(c *gin.Context, sub string) error {
|
||||
return service.queries.DeleteOidcUserInfo(c, sub)
|
||||
}
|
||||
|
||||
func (service *OIDCService) DeleteToken(c *gin.Context, tokenHash string) error {
|
||||
return service.queries.DeleteOidcToken(c, tokenHash)
|
||||
}
|
||||
|
||||
func (service *OIDCService) DeleteTokenByCodeHash(c *gin.Context, codeHash string) error {
|
||||
return service.queries.DeleteOidcTokenByCodeHash(c, codeHash)
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetAccessToken(c *gin.Context, tokenHash string) (repository.OidcToken, error) {
|
||||
entry, err := service.queries.GetOidcToken(c, tokenHash)
|
||||
func (service *OIDCService) GetSessionByToken(ctx context.Context, tokenHash string) (*repository.OidcSession, error) {
|
||||
entry, err := service.queries.GetOIDCSessionByAccessTokenHash(ctx, tokenHash)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return repository.OidcToken{}, ErrTokenNotFound
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
return repository.OidcToken{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if entry.TokenExpiresAt < time.Now().Unix() {
|
||||
// If refresh token is expired, delete the token and userinfo since there is no way for the client to access anything anymore
|
||||
// If refresh token is expired, delete the session
|
||||
// since there is no way for the client to access anything anymore
|
||||
if entry.RefreshTokenExpiresAt < time.Now().Unix() {
|
||||
err := service.DeleteToken(c, tokenHash)
|
||||
// Deletes by sub
|
||||
err := service.queries.DeleteOIDCSessionBySub(ctx, entry.Sub)
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, err
|
||||
}
|
||||
err = service.DeleteUserinfo(c, entry.Sub)
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, err
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return repository.OidcToken{}, ErrTokenExpired
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetUserinfo(c *gin.Context, sub string) (repository.OidcUserinfo, error) {
|
||||
return service.queries.GetOidcUserInfo(c, sub)
|
||||
}
|
||||
|
||||
func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope string) UserinfoResponse {
|
||||
scopes := strings.Split(scope, ",") // split by comma since it's a db entry
|
||||
func (service *OIDCService) CompileUserinfo(user UserinfoResponse, scope string) UserinfoResponse {
|
||||
scopes := strings.Split(scope, " ")
|
||||
userInfo := UserinfoResponse{
|
||||
Sub: user.Sub,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
@@ -714,11 +786,7 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "groups") {
|
||||
if user.Groups != "" {
|
||||
userInfo.Groups = strings.Split(user.Groups, ",")
|
||||
} else {
|
||||
userInfo.Groups = []string{}
|
||||
}
|
||||
userInfo.Groups = user.Groups
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "phone") {
|
||||
@@ -728,10 +796,7 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "address") {
|
||||
var addr model.AddressClaim
|
||||
if err := json.Unmarshal([]byte(user.Address), &addr); err == nil {
|
||||
userInfo.Address = &addr
|
||||
}
|
||||
userInfo.Address = user.Address
|
||||
}
|
||||
|
||||
return userInfo
|
||||
@@ -744,25 +809,16 @@ func (service *OIDCService) Hash(token string) string {
|
||||
}
|
||||
|
||||
func (service *OIDCService) DeleteOldSession(ctx context.Context, sub string) error {
|
||||
err := service.queries.DeleteOidcCodeBySub(ctx, sub)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
err = service.queries.DeleteOidcTokenBySub(ctx, sub)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
err = service.queries.DeleteOidcUserInfo(ctx, sub)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
err := service.queries.DeleteOIDCSessionBySub(ctx, sub)
|
||||
if err != nil && !errors.Is(err, repository.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup routine - Resource heavy due to the linked tables
|
||||
func (service *OIDCService) cleanupRoutine() {
|
||||
func (service *OIDCService) cleanupRoutine(ctx context.Context) {
|
||||
service.log.App.Debug().Msg("Starting OIDC cleanup routine")
|
||||
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
@@ -772,48 +828,18 @@ func (service *OIDCService) cleanupRoutine() {
|
||||
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
// For the OIDC tokens, if they are expired we delete the userinfo and codes
|
||||
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(service.context, repository.DeleteExpiredOidcTokensParams{
|
||||
// Limitation of sqlc, meaning we need to specify a timestamp for both token and refresh token expiry
|
||||
err := service.queries.DeleteExpiredOIDCSessions(ctx, repository.DeleteExpiredOIDCSessionsParams{
|
||||
TokenExpiresAt: currentTime,
|
||||
RefreshTokenExpiresAt: currentTime,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired tokens")
|
||||
}
|
||||
|
||||
for _, expiredToken := range expiredTokens {
|
||||
err := service.DeleteOldSession(service.context, expiredToken.Sub)
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
// For expired codes, we need to get the sub, check if tokens are expired and if they are remove everything
|
||||
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(service.context, currentTime)
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired codes")
|
||||
}
|
||||
|
||||
for _, expiredCode := range expiredCodes {
|
||||
token, err := service.queries.GetOidcTokenBySub(service.context, expiredCode.Sub)
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to get token by sub for expired code")
|
||||
continue
|
||||
}
|
||||
|
||||
if token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {
|
||||
err := service.DeleteOldSession(service.context, expiredCode.Sub)
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired code")
|
||||
}
|
||||
}
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired OIDC sessions")
|
||||
}
|
||||
|
||||
service.log.App.Debug().Msg("Finished OIDC cleanup routine")
|
||||
case <-service.context.Done():
|
||||
case <-ctx.Done():
|
||||
service.log.App.Debug().Msg("Stopping OIDC cleanup routine")
|
||||
return
|
||||
}
|
||||
@@ -853,3 +879,215 @@ func (service *OIDCService) hashAndEncodePKCE(codeVerifier string) string {
|
||||
hasher.Write([]byte(codeVerifier))
|
||||
return base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes.
|
||||
// We will just create a uuid out of the username and client name which remains stable,
|
||||
// but if username or client name changes then sub changes too.
|
||||
func (service *OIDCService) CreateSub(userContext model.UserContext, clientId string) string {
|
||||
return utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.GetUsername(), clientId))
|
||||
}
|
||||
|
||||
func (service *OIDCService) IsCodeUsed(codeHash string) (string, bool) {
|
||||
entry, ok := service.caches.usedCode.Get(codeHash)
|
||||
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return entry.Sub, true
|
||||
}
|
||||
|
||||
func (service *OIDCService) MarkCodeAsUsed(codeHash string, sub string) {
|
||||
entry := UsedCodeEntry{
|
||||
Sub: sub,
|
||||
}
|
||||
service.caches.usedCode.Set(codeHash, entry, 2*time.Minute)
|
||||
}
|
||||
|
||||
func (service *OIDCService) DeleteSessionBySub(ctx context.Context, sub string) error {
|
||||
return service.queries.DeleteOIDCSessionBySub(ctx, sub)
|
||||
}
|
||||
|
||||
func (service *OIDCService) CreateAuthorizeRequestTicket(req AuthorizeRequest) string {
|
||||
ticket := utils.GenerateString(32)
|
||||
|
||||
service.caches.authorize.Set(ticket, req, 10*time.Minute)
|
||||
|
||||
return ticket
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetAuthorizeRequestByTicket(ticket string) (*AuthorizeRequest, bool) {
|
||||
entry, ok := service.caches.authorize.Get(ticket)
|
||||
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &entry, true
|
||||
}
|
||||
|
||||
func (service *OIDCService) DeleteAuthorizeRequestTicket(ticket string) {
|
||||
service.caches.authorize.Delete(ticket)
|
||||
}
|
||||
|
||||
// DecodeAuthorizeJWT TODO: support signed request objects in the future
|
||||
func (service *OIDCService) DecodeAuthorizeJWT(tokenString string) (*AuthorizeRequest, error) {
|
||||
var claims jwt.MapClaims
|
||||
|
||||
token, _, err := jwt.NewParser().ParseUnverified(tokenString, &claims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse authorize request jwt: %w", err)
|
||||
}
|
||||
|
||||
alg, ok := token.Header["alg"].(string)
|
||||
|
||||
if !ok || alg != "none" || string(token.Signature) != "" {
|
||||
return nil, fmt.Errorf("only unsigned jwts are supported for authorize requests")
|
||||
}
|
||||
|
||||
get := func(k string) string {
|
||||
v, _ := claims[k].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
return &AuthorizeRequest{
|
||||
Scope: get("scope"),
|
||||
ResponseType: get("response_type"),
|
||||
ClientID: get("client_id"),
|
||||
RedirectURI: get("redirect_uri"),
|
||||
State: get("state"),
|
||||
Nonce: get("nonce"),
|
||||
CodeChallenge: get("code_challenge"),
|
||||
CodeChallengeMethod: get("code_challenge_method"),
|
||||
Prompt: get("prompt"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetPrompt(prompt string) []OIDCPrompt {
|
||||
if prompt == "" {
|
||||
return []OIDCPrompt{}
|
||||
}
|
||||
|
||||
parsedPromps := make([]OIDCPrompt, 0)
|
||||
prompts := strings.SplitSeq(prompt, " ")
|
||||
|
||||
for p := range prompts {
|
||||
if !slices.Contains(SupportedPrompts, p) {
|
||||
continue
|
||||
}
|
||||
parsedPromps = append(parsedPromps, OIDCPrompt(p))
|
||||
}
|
||||
|
||||
return parsedPromps
|
||||
}
|
||||
|
||||
func (service *OIDCService) getOIDCConsentUnsafe(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) {
|
||||
entry, err := service.queries.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{
|
||||
Username: username,
|
||||
ClientID: clientId,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get oidc consent: %w", err)
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetOIDCConsent(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) {
|
||||
service.mus.consent.RLock()
|
||||
defer service.mus.consent.RUnlock()
|
||||
return service.getOIDCConsentUnsafe(ctx, username, clientId)
|
||||
}
|
||||
|
||||
func (service *OIDCService) UpsertOIDCConsent(ctx context.Context, username, scope, clientId string) (repository.OidcConsent, error) {
|
||||
service.mus.consent.Lock()
|
||||
defer service.mus.consent.Unlock()
|
||||
|
||||
existing, err := service.getOIDCConsentUnsafe(ctx, username, clientId)
|
||||
|
||||
if err != nil {
|
||||
return repository.OidcConsent{}, err
|
||||
}
|
||||
|
||||
merged := scope
|
||||
|
||||
if existing != nil {
|
||||
merged = mergeScopes(existing.Scope, scope)
|
||||
}
|
||||
|
||||
entry := repository.UpsertOIDCConsentParams{
|
||||
Username: username,
|
||||
Scope: merged,
|
||||
ClientID: clientId,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
consent, err := service.queries.UpsertOIDCConsent(ctx, entry)
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Error().Err(err).Msg("Failed to upsert OIDC consent")
|
||||
return repository.OidcConsent{}, err
|
||||
}
|
||||
|
||||
return consent, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) reconcileOIDCConsents(ctx context.Context) error {
|
||||
consents, err := service.queries.ListOIDCConsents(ctx)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list oidc consents: %w", err)
|
||||
}
|
||||
|
||||
cleaned := make(map[string]struct{})
|
||||
|
||||
for _, consent := range consents {
|
||||
if _, ok := cleaned[consent.ClientID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
cleaned[consent.ClientID] = struct{}{}
|
||||
|
||||
if _, ok := service.clients[consent.ClientID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
service.log.App.Info().Str("clientId", consent.ClientID).Msg("Removed OIDC client no longer in configuration, deleting its consents")
|
||||
|
||||
if err := service.queries.DeleteOIDCConsentByClientID(ctx, consent.ClientID); err != nil {
|
||||
service.log.App.Warn().Err(err).Str("clientId", consent.ClientID).Msg("Failed to delete OIDC consents for removed client")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeScopes(existing, requested string) string {
|
||||
set := make(map[string]struct{})
|
||||
|
||||
for _, scope := range strings.Split(existing, " ") {
|
||||
if scope != "" {
|
||||
set[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, scope := range strings.Split(requested, " ") {
|
||||
if scope != "" {
|
||||
set[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
scopes := make([]string, 0, len(set))
|
||||
|
||||
for scope := range set {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
|
||||
slices.Sort(scopes)
|
||||
|
||||
return strings.Join(scopes, " ")
|
||||
}
|
||||
|
||||
@@ -1,37 +1,25 @@
|
||||
package service_test
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func newTestUser() repository.OidcUserinfo {
|
||||
addr := model.AddressClaim{
|
||||
Formatted: "123 Main St",
|
||||
StreetAddress: "123 Main St",
|
||||
Locality: "Springfield",
|
||||
Region: "IL",
|
||||
PostalCode: "62701",
|
||||
Country: "US",
|
||||
}
|
||||
addrJSON, _ := json.Marshal(addr)
|
||||
|
||||
return repository.OidcUserinfo{
|
||||
func newTestUser() UserinfoResponse {
|
||||
return UserinfoResponse{
|
||||
Sub: "test-sub",
|
||||
Name: "Test User",
|
||||
PreferredUsername: "testuser",
|
||||
Email: "test@example.com",
|
||||
Groups: "admins,users",
|
||||
Groups: []string{"admins", "users"},
|
||||
UpdatedAt: 1234567890,
|
||||
GivenName: "Test",
|
||||
FamilyName: "User",
|
||||
@@ -45,7 +33,14 @@ func newTestUser() repository.OidcUserinfo {
|
||||
Zoneinfo: "America/Chicago",
|
||||
Locale: "en-US",
|
||||
PhoneNumber: "+15555550100",
|
||||
Address: string(addrJSON),
|
||||
Address: &model.AddressClaim{
|
||||
Formatted: "123 Main St",
|
||||
StreetAddress: "123 Main St",
|
||||
Locality: "Springfield",
|
||||
Region: "IL",
|
||||
PostalCode: "62701",
|
||||
Country: "US",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,23 +65,31 @@ func TestCompileUserinfo(t *testing.T) {
|
||||
log.Init()
|
||||
|
||||
ctx := context.TODO()
|
||||
wg := &sync.WaitGroup{}
|
||||
dg := ding.New(ctx)
|
||||
|
||||
svc, err := service.NewOIDCService(log, cfg, runtime, nil, ctx, wg)
|
||||
store := memory.New()
|
||||
|
||||
svc, err := NewOIDCService(OIDCServiceInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
Runtime: &runtime,
|
||||
Queries: store,
|
||||
Ding: dg,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
type testCase struct {
|
||||
description string
|
||||
mutate func(u *repository.OidcUserinfo)
|
||||
mutate func(u *UserinfoResponse)
|
||||
scope string
|
||||
run func(t *testing.T, info service.UserinfoResponse)
|
||||
run func(t *testing.T, info UserinfoResponse)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "openid scope only returns sub and updated_at",
|
||||
scope: "openid",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
run: func(t *testing.T, info UserinfoResponse) {
|
||||
assert.Equal(t, "test-sub", info.Sub)
|
||||
assert.Equal(t, int64(1234567890), info.UpdatedAt)
|
||||
assert.Empty(t, info.Name)
|
||||
@@ -98,8 +101,8 @@ func TestCompileUserinfo(t *testing.T) {
|
||||
},
|
||||
{
|
||||
description: "profile scope returns all profile fields",
|
||||
scope: "openid,profile",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
scope: "openid profile",
|
||||
run: func(t *testing.T, info UserinfoResponse) {
|
||||
assert.Equal(t, "Test User", info.Name)
|
||||
assert.Equal(t, "testuser", info.PreferredUsername)
|
||||
assert.Equal(t, "Test", info.GivenName)
|
||||
@@ -118,8 +121,8 @@ func TestCompileUserinfo(t *testing.T) {
|
||||
},
|
||||
{
|
||||
description: "email scope sets email and email_verified true when email present",
|
||||
scope: "openid,email",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
scope: "openid email",
|
||||
run: func(t *testing.T, info UserinfoResponse) {
|
||||
assert.Equal(t, "test@example.com", info.Email)
|
||||
assert.True(t, info.EmailVerified)
|
||||
assert.Empty(t, info.Name)
|
||||
@@ -127,17 +130,17 @@ func TestCompileUserinfo(t *testing.T) {
|
||||
},
|
||||
{
|
||||
description: "email scope sets email_verified false when email absent",
|
||||
scope: "openid,email",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.Email = "" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
scope: "openid email",
|
||||
mutate: func(u *UserinfoResponse) { u.Email = "" },
|
||||
run: func(t *testing.T, info UserinfoResponse) {
|
||||
assert.Empty(t, info.Email)
|
||||
assert.False(t, info.EmailVerified)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "phone scope sets phone_number_verified true when phone present",
|
||||
scope: "openid,phone",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
scope: "openid phone",
|
||||
run: func(t *testing.T, info UserinfoResponse) {
|
||||
assert.Equal(t, "+15555550100", info.PhoneNumber)
|
||||
require.NotNil(t, info.PhoneNumberVerified)
|
||||
assert.True(t, *info.PhoneNumberVerified)
|
||||
@@ -145,17 +148,17 @@ func TestCompileUserinfo(t *testing.T) {
|
||||
},
|
||||
{
|
||||
description: "phone scope sets phone_number_verified false when phone absent",
|
||||
scope: "openid,phone",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.PhoneNumber = "" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
scope: "openid phone",
|
||||
mutate: func(u *UserinfoResponse) { u.PhoneNumber = "" },
|
||||
run: func(t *testing.T, info UserinfoResponse) {
|
||||
require.NotNil(t, info.PhoneNumberVerified)
|
||||
assert.False(t, *info.PhoneNumberVerified)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "address scope returns parsed address",
|
||||
scope: "openid,address",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
scope: "openid address",
|
||||
run: func(t *testing.T, info UserinfoResponse) {
|
||||
require.NotNil(t, info.Address)
|
||||
assert.Equal(t, "123 Main St", info.Address.Formatted)
|
||||
assert.Equal(t, "123 Main St", info.Address.StreetAddress)
|
||||
@@ -165,33 +168,17 @@ func TestCompileUserinfo(t *testing.T) {
|
||||
assert.Equal(t, "US", info.Address.Country)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "address scope with invalid JSON omits address",
|
||||
scope: "openid,address",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.Address = "not-valid-json" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Nil(t, info.Address)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "groups scope returns split groups",
|
||||
scope: "openid,groups",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
scope: "openid groups",
|
||||
run: func(t *testing.T, info UserinfoResponse) {
|
||||
assert.Equal(t, []string{"admins", "users"}, info.Groups)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "groups scope returns empty slice when no groups",
|
||||
scope: "openid,groups",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.Groups = "" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, []string{}, info.Groups)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "all scopes return all fields",
|
||||
scope: "openid,profile,email,phone,address,groups",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
scope: "openid profile email phone address groups",
|
||||
run: func(t *testing.T, info UserinfoResponse) {
|
||||
assert.Equal(t, "Test User", info.Name)
|
||||
assert.Equal(t, "test@example.com", info.Email)
|
||||
assert.Equal(t, "+15555550100", info.PhoneNumber)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
type Policy string
|
||||
|
||||
const (
|
||||
PolicyAllow Policy = "allow"
|
||||
PolicyDeny Policy = "deny"
|
||||
)
|
||||
|
||||
type Effect int
|
||||
|
||||
const (
|
||||
EffectAbstain Effect = iota
|
||||
EffectAllow
|
||||
EffectDeny
|
||||
)
|
||||
|
||||
type Rule interface {
|
||||
Evaluate(ctx *ACLContext) Effect
|
||||
}
|
||||
|
||||
type ACLContext struct {
|
||||
ACLs *model.App
|
||||
UserContext *model.UserContext
|
||||
IP net.IP
|
||||
Path string
|
||||
TrustedProxiesConfigured bool
|
||||
}
|
||||
|
||||
type PolicyEngine struct {
|
||||
log *logger.Logger
|
||||
rules map[RuleName]Rule
|
||||
policy Policy
|
||||
}
|
||||
|
||||
type PolicyEngineInput struct {
|
||||
dig.In
|
||||
|
||||
Log *logger.Logger
|
||||
Config *model.Config
|
||||
}
|
||||
|
||||
func NewPolicyEngine(i PolicyEngineInput) (*PolicyEngine, error) {
|
||||
engine := PolicyEngine{
|
||||
log: i.Log,
|
||||
rules: make(map[RuleName]Rule),
|
||||
}
|
||||
|
||||
switch i.Config.Auth.ACLs.Policy {
|
||||
case string(PolicyAllow):
|
||||
i.Log.App.Debug().Msg("Using 'allow' ACL policy: access to apps will be allowed by default unless explicitly blocked")
|
||||
engine.policy = PolicyAllow
|
||||
case string(PolicyDeny):
|
||||
i.Log.App.Debug().Msg("Using 'deny' ACL policy: access to apps will be blocked by default unless explicitly allowed")
|
||||
engine.policy = PolicyDeny
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid acl policy: %s", i.Config.Auth.ACLs.Policy)
|
||||
}
|
||||
|
||||
return &engine, nil
|
||||
}
|
||||
|
||||
func (engine *PolicyEngine) RegisterRule(name RuleName, rule Rule) {
|
||||
engine.log.App.Debug().Str("rule", string(name)).Msg("Registering ACL rule in policy engine")
|
||||
engine.rules[name] = rule
|
||||
}
|
||||
|
||||
func (engine *PolicyEngine) evaluateRuleByName(name RuleName, ctx *ACLContext) Effect {
|
||||
rule, exists := engine.rules[name]
|
||||
|
||||
if !exists {
|
||||
engine.log.App.Warn().Str("rule", string(name)).Msg("Rule not found in policy engine, defaulting to deny")
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
return rule.Evaluate(ctx)
|
||||
}
|
||||
|
||||
func (engine *PolicyEngine) effectToAccess(effect Effect) bool {
|
||||
switch effect {
|
||||
case EffectAllow:
|
||||
return true
|
||||
case EffectDeny:
|
||||
return false
|
||||
default:
|
||||
// If the effect is abstain, we fall back to the default policy
|
||||
return engine.policy == PolicyAllow
|
||||
}
|
||||
}
|
||||
|
||||
func (engine *PolicyEngine) Evaluate(name RuleName, ctx *ACLContext) bool {
|
||||
effect := engine.evaluateRuleByName(name, ctx)
|
||||
access := engine.effectToAccess(effect)
|
||||
|
||||
engine.log.App.Debug().
|
||||
Str("rule", string(name)).
|
||||
Int("effect", int(effect)).
|
||||
Bool("access", access).
|
||||
Msg("Evaluated ACL rule")
|
||||
|
||||
return access
|
||||
}
|
||||
|
||||
func (engine *PolicyEngine) Policy() Policy {
|
||||
return engine.policy
|
||||
}
|
||||
|
||||
func (engine *PolicyEngine) Rules() map[RuleName]Rule {
|
||||
return engine.rules
|
||||
}
|
||||
|
||||
func (engine *PolicyEngine) EvaluateFunc(f func() Effect) bool {
|
||||
return engine.effectToAccess(f())
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
// Create test rule
|
||||
type TestRule struct{}
|
||||
|
||||
func (rule *TestRule) Evaluate(ctx *ACLContext) Effect {
|
||||
switch ctx.Path {
|
||||
case "/allowed":
|
||||
return EffectAllow
|
||||
case "/denied":
|
||||
return EffectDeny
|
||||
default:
|
||||
return EffectAbstain
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyEngine(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
cfg, _ := test.CreateTestConfigs(t)
|
||||
|
||||
testRule := &TestRule{}
|
||||
|
||||
// Engine should fail with invalid policy
|
||||
cfg.Auth.ACLs.Policy = "invalid_policy"
|
||||
_, err := NewPolicyEngine(PolicyEngineInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
// Engine should initialize with 'allow' policy
|
||||
cfg.Auth.ACLs.Policy = string(PolicyAllow)
|
||||
engine, err := NewPolicyEngine(PolicyEngineInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, PolicyAllow, engine.Policy())
|
||||
|
||||
// Engine should initialize with 'deny' policy
|
||||
cfg.Auth.ACLs.Policy = string(PolicyDeny)
|
||||
engine, err = NewPolicyEngine(PolicyEngineInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, PolicyDeny, engine.Policy())
|
||||
|
||||
// Engine should allow adding rules
|
||||
engine, err = NewPolicyEngine(PolicyEngineInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
engine.RegisterRule("test-rule", testRule)
|
||||
_, ok := engine.Rules()["test-rule"]
|
||||
assert.True(t, ok)
|
||||
|
||||
// Begin allow policy tests
|
||||
cfg.Auth.ACLs.Policy = string(PolicyAllow)
|
||||
engine, err = NewPolicyEngine(PolicyEngineInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
engine.RegisterRule("test-rule", testRule)
|
||||
|
||||
// With allow policy, if rule allows, access should be allowed
|
||||
ctx := &ACLContext{Path: "/allowed"}
|
||||
assert.Equal(t, true, engine.Evaluate("test-rule", ctx))
|
||||
|
||||
// With allow policy, if rule denies, access should be denied
|
||||
ctx.Path = "/denied"
|
||||
assert.Equal(t, false, engine.Evaluate("test-rule", ctx))
|
||||
|
||||
// With allow policy, if rule abstains, access should be allowed (default)
|
||||
ctx.Path = "/abstain"
|
||||
assert.Equal(t, true, engine.Evaluate("test-rule", ctx))
|
||||
|
||||
// Begin deny policy tests
|
||||
cfg.Auth.ACLs.Policy = string(PolicyDeny)
|
||||
engine, err = NewPolicyEngine(PolicyEngineInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
engine.RegisterRule("test-rule", testRule)
|
||||
|
||||
// With deny policy, if rule allows, access should be allowed
|
||||
ctx.Path = "/allowed"
|
||||
assert.Equal(t, true, engine.Evaluate("test-rule", ctx))
|
||||
|
||||
// With deny policy, if rule denies, access should be denied
|
||||
ctx.Path = "/denied"
|
||||
assert.Equal(t, false, engine.Evaluate("test-rule", ctx))
|
||||
|
||||
// With deny policy, if rule abstains, access should be denied (default)
|
||||
ctx.Path = "/abstain"
|
||||
assert.Equal(t, false, engine.Evaluate("test-rule", ctx))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func simpleReq[T any](client *http.Client, ctx context.Context, url string, headers map[string]string) (*T, error) {
|
||||
var decodedRes T
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed with status: %s", res.Status)
|
||||
}
|
||||
return nil, fmt.Errorf("request failed with status: %s and body: %s", res.Status, body)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &decodedRes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &decodedRes, nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/cache"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
const tailscaleAPIBaseURL = "https://api.tailscale.com/api/v2"
|
||||
|
||||
var (
|
||||
tailscaleAPIDeviceList = func(tailnet string) string {
|
||||
return tailscaleAPIBaseURL + "/tailnet/" + tailnet + "/devices"
|
||||
}
|
||||
tailscaleAPIUserList = func(tailnet string) string {
|
||||
return tailscaleAPIBaseURL + "/tailnet/" + tailnet + "/users"
|
||||
}
|
||||
)
|
||||
|
||||
type tailscaleDevice struct {
|
||||
Addresses []string `json:"addresses"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type tailscaleAPIDevices struct {
|
||||
Devices []tailscaleDevice `json:"devices"`
|
||||
}
|
||||
|
||||
type tailscaleUser struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
LoginName string `json:"loginName"`
|
||||
}
|
||||
|
||||
type tailscaleAPIUsers struct {
|
||||
Users []tailscaleUser `json:"users"`
|
||||
}
|
||||
|
||||
type TailscaleWhoisResponse struct {
|
||||
DisplayName string
|
||||
LoginName string
|
||||
NodeName string
|
||||
}
|
||||
|
||||
type TailscaleService struct {
|
||||
config *model.Config
|
||||
log *logger.Logger
|
||||
client *http.Client
|
||||
ctx context.Context
|
||||
|
||||
apiToken string
|
||||
|
||||
caches struct {
|
||||
devices *cache.CacheStore[tailscaleAPIDevices]
|
||||
users *cache.CacheStore[tailscaleAPIUsers]
|
||||
}
|
||||
|
||||
urls struct {
|
||||
devices string
|
||||
users string
|
||||
}
|
||||
}
|
||||
|
||||
type TailscaleServiceInput struct {
|
||||
dig.In
|
||||
|
||||
Ding *ding.Ding
|
||||
Config *model.Config
|
||||
Log *logger.Logger
|
||||
Ctx context.Context
|
||||
}
|
||||
|
||||
func NewTailscaleService(i TailscaleServiceInput) (*TailscaleService, error) {
|
||||
if !i.Config.Tailscale.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if i.Config.Tailscale.Tailnet == "" {
|
||||
return nil, fmt.Errorf("tailscale tailnet not set")
|
||||
}
|
||||
|
||||
apiToken := utils.GetSecret(i.Config.Tailscale.APIToken, i.Config.Tailscale.APITokenFile)
|
||||
|
||||
if apiToken == "" {
|
||||
return nil, fmt.Errorf("tailscale api token not set")
|
||||
}
|
||||
|
||||
s := &TailscaleService{
|
||||
config: i.Config,
|
||||
log: i.Log,
|
||||
ctx: i.Ctx,
|
||||
apiToken: apiToken,
|
||||
}
|
||||
|
||||
devicesCache := cache.NewCacheStore[tailscaleAPIDevices](0)
|
||||
usersCache := cache.NewCacheStore[tailscaleAPIUsers](0)
|
||||
|
||||
s.caches.devices = devicesCache
|
||||
s.caches.users = usersCache
|
||||
|
||||
i.Ding.Go(func(ctx context.Context) {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.caches.devices.Sweep()
|
||||
s.caches.users.Sweep()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}, ding.RingMinor)
|
||||
|
||||
s.urls.devices = tailscaleAPIDeviceList(i.Config.Tailscale.Tailnet)
|
||||
s.urls.users = tailscaleAPIUserList(i.Config.Tailscale.Tailnet)
|
||||
s.client = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
_, _, err := s.getDeviceList()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get device list: %w", err)
|
||||
}
|
||||
|
||||
_, _, err = s.getUsersList()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user list: %w", err)
|
||||
}
|
||||
|
||||
s.log.App.Info().Msg("Tailscale service initialized")
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *TailscaleService) buildAuthorizationHeader() string {
|
||||
return "Bearer " + s.apiToken
|
||||
}
|
||||
|
||||
func (s *TailscaleService) getDeviceList() (*tailscaleAPIDevices, bool, error) {
|
||||
cached, ok := s.caches.devices.Get("devices")
|
||||
|
||||
if ok {
|
||||
return &cached, true, nil
|
||||
}
|
||||
|
||||
devices, err := simpleReq[tailscaleAPIDevices](s.client, s.ctx, s.urls.devices, map[string]string{
|
||||
"Authorization": s.buildAuthorizationHeader(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to get device list: %w", err)
|
||||
}
|
||||
|
||||
s.caches.devices.Set("devices", *devices, time.Duration(s.config.Tailscale.CacheDuration)*time.Second)
|
||||
|
||||
return devices, false, nil
|
||||
}
|
||||
|
||||
func (s *TailscaleService) getUsersList() (*tailscaleAPIUsers, bool, error) {
|
||||
cached, ok := s.caches.users.Get("users")
|
||||
|
||||
if ok {
|
||||
return &cached, true, nil
|
||||
}
|
||||
|
||||
users, err := simpleReq[tailscaleAPIUsers](s.client, s.ctx, s.urls.users, map[string]string{
|
||||
"Authorization": s.buildAuthorizationHeader(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to get user list: %w", err)
|
||||
}
|
||||
|
||||
s.caches.users.Set("users", *users, time.Duration(s.config.Tailscale.CacheDuration)*time.Second)
|
||||
|
||||
return users, false, nil
|
||||
}
|
||||
|
||||
func (s *TailscaleService) Whois(addr string) (*TailscaleWhoisResponse, error) {
|
||||
var device *tailscaleDevice
|
||||
|
||||
devices, dCacheHit, err := s.getDeviceList()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get device list: %w", err)
|
||||
}
|
||||
|
||||
for _, d := range devices.Devices {
|
||||
if len(d.Tags) != 0 {
|
||||
continue
|
||||
}
|
||||
for _, a := range d.Addresses {
|
||||
if a == addr {
|
||||
device = &d
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if device == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
s.log.App.Debug().Str("device", device.Name).Bool("cache_hit", dCacheHit).Msg("Tailscale device found")
|
||||
|
||||
var user *tailscaleUser
|
||||
|
||||
users, uCacheHit, err := s.getUsersList()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user list: %w", err)
|
||||
}
|
||||
|
||||
for _, u := range users.Users {
|
||||
if u.LoginName == device.User {
|
||||
user = &u
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
s.log.App.Debug().Str("user", user.LoginName).Bool("cache_hit", uCacheHit).Msg("Tailscale user found")
|
||||
|
||||
return &TailscaleWhoisResponse{
|
||||
LoginName: user.LoginName,
|
||||
DisplayName: user.DisplayName,
|
||||
NodeName: device.Name,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user