Commit beffad71 authored by ale's avatar ale

Refactor internal interfaces for clarity

Move from Config-objects-with-private-members to a full separation of
configuration and runtime types, which simplifies a lot of interface
boundaries between different parts of the code.

More specifically, the Server is a lot leaner and its functionality is
clearer. Backends also have a slightly improved interface.
parent fdccd853
Pipeline #2165 passed with stage
in 23 seconds
This diff is collapsed.
......@@ -79,25 +79,27 @@ var (
`
testConfigStr = `---
enabled_backends:
- file
services:
test:
backends:
- { file: users.yml }
- backend: file
params:
src: users.yml
interactive:
challenge_response: true
backends:
- { file: users.yml }
- backend: file
params:
src: users.yml
`
testConfigStrWithRatelimit = `---
enabled_backends:
- file
services:
test:
backends:
- { file: users.yml }
- backend: file
params:
src: users.yml
rate_limits:
- failed_login_bl
rate_limits:
......@@ -230,6 +232,6 @@ func TestAuthServer_Blacklist_BelowLimit(t *testing.T) {
Password: []byte("password"),
})
if resp.Status != auth.StatusOK {
t.Fatalf("user was incorrectly blacklisted: %+v", s.srv.config.Services["test"])
t.Fatal("user was incorrectly blacklisted")
}
}
......@@ -2,13 +2,18 @@ package server
import (
"context"
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"gopkg.in/yaml.v2"
)
type fileBackendUser struct {
// BackendSpec parameters for the file backend.
type fileServiceParams struct {
Src string `yaml:"src"`
}
type fileUser struct {
Name string `yaml:"name"`
Email string `yaml:"email"`
Shard string `yaml:"shard"`
......@@ -17,7 +22,7 @@ type fileBackendUser struct {
Groups []string `yaml:"groups"`
}
func (f *fileBackendUser) ToUser() *User {
func (f *fileUser) ToUser() *User {
return &User{
Name: f.Name,
Email: f.Email,
......@@ -31,56 +36,67 @@ func (f *fileBackendUser) ToUser() *User {
// Simple file-based authentication backend, list users and their
// credentials in a YAML-encoded file.
type fileBackend struct {
files map[string]map[string]fileBackendUser
files map[string]map[string]*fileUser
configDir string
}
func loadUsersFile(path string) (map[string]fileBackendUser, error) {
data, err := ioutil.ReadFile(path) // #nosec
if err != nil {
return nil, err
}
var userList []fileBackendUser
if err := yaml.Unmarshal(data, &userList); err != nil {
func loadUsersFile(path string) (map[string]*fileUser, error) {
var userList []*fileUser
if err := loadYAML(path, &userList); err != nil {
return nil, err
}
users := make(map[string]fileBackendUser)
users := make(map[string]*fileUser)
for _, u := range userList {
users[u.Name] = u
}
return users, nil
}
func newFileBackend(config *Config) (*fileBackend, error) {
// Iterate over all file service backend specs.
filesMap := make(map[string]map[string]fileBackendUser)
err := config.walkBackendSpecs(func(spec *BackendSpec) error {
if spec.FileSpec != "" {
if _, ok := filesMap[spec.FileSpec]; ok {
return nil
}
m, err := loadUsersFile(config.relativePath(spec.FileSpec))
if err != nil {
return fmt.Errorf("%s: %v", spec.FileSpec, err)
}
if len(m) == 0 {
return fmt.Errorf("%s: empty user file", spec.FileSpec)
}
filesMap[spec.FileSpec] = m
func newFileBackend(config *Config, _ yaml.MapSlice) (*fileBackend, error) {
return &fileBackend{
files: make(map[string]map[string]*fileUser),
configDir: filepath.Dir(config.path),
}, nil
}
func (b *fileBackend) relativePath(path string) string {
if strings.HasPrefix(path, "/") {
return path
}
return filepath.Join(b.configDir, path)
}
func (b *fileBackend) getUserMap(path string) (map[string]*fileUser, error) {
m, ok := b.files[path]
if !ok {
var err error
m, err = loadUsersFile(path)
if err != nil {
return nil, err
}
return nil
})
return &fileBackend{files: filesMap}, err
b.files[path] = m
}
return m, nil
}
func (b *fileBackend) Close() {}
func (b *fileBackend) GetUser(_ context.Context, spec *BackendSpec, name string) (*User, bool) {
if spec.FileSpec == "" {
return nil, false
func (b *fileBackend) NewServiceBackend(spec *BackendSpec) (serviceBackend, error) {
var params fileServiceParams
if err := unmarshalMapSlice(spec.Params, &params); err != nil {
return nil, err
}
m, err := b.getUserMap(b.relativePath(params.Src))
if err != nil {
return nil, err
}
return fileServiceBackend(m), nil
}
type fileServiceBackend map[string]*fileUser
userMap := b.files[spec.FileSpec]
u, ok := userMap[name]
func (b fileServiceBackend) GetUser(_ context.Context, name string) (*User, bool) {
u, ok := b[name]
if !ok {
return nil, false
}
......
......@@ -10,11 +10,12 @@ import (
ldaputil "git.autistici.org/ai3/go-common/ldap"
"github.com/tstranex/u2f"
"gopkg.in/ldap.v2"
"gopkg.in/yaml.v2"
)
// LDAPServiceConfig defines a search to be performed when looking up
// ldapServiceParams defines a search to be performed when looking up
// a user for a service.
type LDAPServiceConfig struct {
type ldapServiceParams struct {
// SearchBase, SearchFilter and Scope define parameters for
// the LDAP search. The search should return a single object.
// SearchBase or SearchFilter should contain the string "%s",
......@@ -22,8 +23,7 @@ type LDAPServiceConfig struct {
// a query.
SearchBase string `yaml:"search_base"`
SearchFilter string `yaml:"search_filter"`
ScopeStr string `yaml:"scope"`
scope int
Scope string `yaml:"scope"`
// Attrs tells us which LDAP attributes to query to find user
// attributes. It is encoded as a {user_attribute:
......@@ -33,33 +33,6 @@ type LDAPServiceConfig struct {
Attrs map[string]string `yaml:"attrs"`
}
// Valid returns an error if the configuration is invalid.
func (c *LDAPServiceConfig) Valid() error {
if c.SearchBase == "" {
return errors.New("empty search_base")
}
if c.SearchFilter == "" {
return errors.New("empty search_filter")
}
c.scope = ldap.ScopeWholeSubtree
if c.ScopeStr != "" {
s, err := ldaputil.ParseScope(c.ScopeStr)
if err != nil {
return err
}
c.scope = s
}
return nil
}
func (c *LDAPServiceConfig) attributes() []string {
var attrs []string
for _, attrSrc := range c.Attrs {
attrs = append(attrs, attrSrc)
}
return attrs
}
// The default attribute mapping just happens to match our schema.
var defaultLDAPAttributeMap = map[string]string{
"password": "userPassword",
......@@ -68,57 +41,6 @@ var defaultLDAPAttributeMap = map[string]string{
"u2f_registration": "u2fRegistration",
}
func (c *LDAPServiceConfig) compile() error {
// Merge in attributes from the default map if unset.
for attrDst, attrSrc := range defaultLDAPAttributeMap {
if _, ok := c.Attrs[attrDst]; !ok {
c.Attrs[attrDst] = attrSrc
}
}
return nil
}
func (c *LDAPServiceConfig) searchRequest(username string) *ldap.SearchRequest {
base := strings.Replace(c.SearchBase, "%s", escapeDN(username), -1)
filter := strings.Replace(c.SearchFilter, "%s", ldap.EscapeFilter(username), -1)
return ldap.NewSearchRequest(
base,
c.scope,
ldap.NeverDerefAliases,
0,
0,
false,
filter,
c.attributes(),
nil,
)
}
// Build a User object from a LDAP response.
func (c *LDAPServiceConfig) userFromResponse(username string, result *ldap.SearchResult) (*User, bool) {
if len(result.Entries) < 1 {
return nil, false
}
// TODO: return an error if more than one entry is returned.
entry := result.Entries[0]
// Apply the attribute map. We don't care if an attribute is
// not defined in the map, as the get* functions will silently
// ignore an empty attribute name.
u := User{
Name: username,
Email: getStringFromLDAPEntry(entry, c.Attrs["email"]),
Shard: getStringFromLDAPEntry(entry, c.Attrs["shard"]),
EncryptedPassword: []byte(dropCryptPrefix(getStringFromLDAPEntry(entry, c.Attrs["password"]))),
TOTPSecret: getStringFromLDAPEntry(entry, c.Attrs["totp_secret"]),
AppSpecificPasswords: decodeAppSpecificPasswordList(getListFromLDAPEntry(entry, c.Attrs["app_specific_password"])),
U2FRegistrations: decodeU2FRegistrationList(getListFromLDAPEntry(entry, c.Attrs["u2f_registration"])),
}
return &u, true
}
func dropCryptPrefix(s string) string {
if strings.HasPrefix(s, "{crypt}") || strings.HasPrefix(s, "{CRYPT}") {
return s[7:]
......@@ -176,8 +98,8 @@ func decodeU2FRegistrationList(encRegs []string) []u2f.Registration {
return out
}
// LDAPConfig holds the global configuration for the LDAP user backend.
type LDAPConfig struct {
// Global configuration for the LDAP user backend.
type ldapConfig struct {
URI string `yaml:"uri"`
BindDN string `yaml:"bind_dn"`
BindPw string `yaml:"bind_pw"`
......@@ -185,7 +107,7 @@ type LDAPConfig struct {
}
// Valid returns an error if the configuration is invalid.
func (c *LDAPConfig) Valid() error {
func (c *ldapConfig) valid() error {
if c.URI == "" {
return errors.New("empty uri")
}
......@@ -199,34 +121,24 @@ func (c *LDAPConfig) Valid() error {
}
type ldapBackend struct {
config *LDAPConfig
config *ldapConfig
pool *ldaputil.ConnectionPool
}
func newLDAPBackend(config *Config) (*ldapBackend, error) {
// Validate configuration.
if err := config.LDAPConfig.Valid(); err != nil {
func newLDAPBackend(config *Config, params yaml.MapSlice) (*ldapBackend, error) {
// Unmarshal and validate configuration.
var lc ldapConfig
if err := unmarshalMapSlice(params, &lc); err != nil {
return nil, err
}
// Check validity of all LDAP service configs, so we can
// return an error early.
if err := config.walkBackendSpecs(func(spec *BackendSpec) error {
if spec.LDAPSpec == nil {
return nil
}
if err := spec.LDAPSpec.Valid(); err != nil {
return err
}
return spec.LDAPSpec.compile()
}); err != nil {
if err := lc.valid(); err != nil {
return nil, err
}
// Read the bind password.
bindPw := config.LDAPConfig.BindPw
if config.LDAPConfig.BindPwFile != "" {
pwData, err := ioutil.ReadFile(config.LDAPConfig.BindPwFile)
bindPw := lc.BindPw
if lc.BindPwFile != "" {
pwData, err := ioutil.ReadFile(lc.BindPwFile)
if err != nil {
return nil, err
}
......@@ -234,13 +146,13 @@ func newLDAPBackend(config *Config) (*ldapBackend, error) {
}
// Initialize the connection pool.
pool, err := ldaputil.NewConnectionPool(config.LDAPConfig.URI, config.LDAPConfig.BindDN, bindPw, 5)
pool, err := ldaputil.NewConnectionPool(lc.URI, lc.BindDN, bindPw, 5)
if err != nil {
return nil, err
}
return &ldapBackend{
config: config.LDAPConfig,
config: &lc,
pool: pool,
}, nil
}
......@@ -249,18 +161,110 @@ func (b *ldapBackend) Close() {
b.pool.Close()
}
func (b *ldapBackend) GetUser(ctx context.Context, spec *BackendSpec, name string) (*User, bool) {
serviceConfig := spec.LDAPSpec
if serviceConfig == nil {
func (b *ldapBackend) NewServiceBackend(spec *BackendSpec) (serviceBackend, error) {
var params ldapServiceParams
if err := unmarshalMapSlice(spec.Params, &params); err != nil {
return nil, err
}
return newLDAPServiceBackend(b.pool, &params)
}
type ldapServiceBackend struct {
pool *ldaputil.ConnectionPool
base string
filter string
scope int
attrList []string
attrs map[string]string
}
func newLDAPServiceBackend(pool *ldaputil.ConnectionPool, params *ldapServiceParams) (*ldapServiceBackend, error) {
if params.SearchBase == "" {
return nil, errors.New("empty search_base")
}
if params.SearchFilter == "" {
return nil, errors.New("empty search_filter")
}
scope := ldap.ScopeWholeSubtree
if params.Scope != "" {
s, err := ldaputil.ParseScope(params.Scope)
if err != nil {
return nil, err
}
scope = s
}
// Merge in attributes from the default map if unset, and
// convert them to a list to pass to NewSearchRequest.
for attrDst, attrSrc := range defaultLDAPAttributeMap {
if _, ok := params.Attrs[attrDst]; !ok {
params.Attrs[attrDst] = attrSrc
}
}
var attrList []string
for _, attrSrc := range params.Attrs {
attrList = append(attrList, attrSrc)
}
return &ldapServiceBackend{
pool: pool,
base: params.SearchBase,
filter: params.SearchFilter,
scope: scope,
attrList: attrList,
attrs: params.Attrs,
}, nil
}
// Build a SearchRequest for this username.
func (b *ldapServiceBackend) searchRequest(username string) *ldap.SearchRequest {
base := strings.Replace(b.base, "%s", escapeDN(username), -1)
filter := strings.Replace(b.filter, "%s", ldap.EscapeFilter(username), -1)
return ldap.NewSearchRequest(
base,
b.scope,
ldap.NeverDerefAliases,
0,
0,
false,
filter,
b.attrList,
nil,
)
}
// Build a User object from a LDAP response.
func (b *ldapServiceBackend) userFromResponse(username string, result *ldap.SearchResult) (*User, bool) {
if len(result.Entries) < 1 {
return nil, false
}
// TODO: return an error if more than one entry is returned.
entry := result.Entries[0]
// Apply the attribute map. We don't care if an attribute is
// not defined in the map, as the get* functions will silently
// ignore an empty attribute name.
u := User{
Name: username,
Email: getStringFromLDAPEntry(entry, b.attrs["email"]),
Shard: getStringFromLDAPEntry(entry, b.attrs["shard"]),
EncryptedPassword: []byte(dropCryptPrefix(getStringFromLDAPEntry(entry, b.attrs["password"]))),
TOTPSecret: getStringFromLDAPEntry(entry, b.attrs["totp_secret"]),
AppSpecificPasswords: decodeAppSpecificPasswordList(getListFromLDAPEntry(entry, b.attrs["app_specific_password"])),
U2FRegistrations: decodeU2FRegistrationList(getListFromLDAPEntry(entry, b.attrs["u2f_registration"])),
}
return &u, true
}
result, err := b.pool.Search(ctx, serviceConfig.searchRequest(name))
func (b *ldapServiceBackend) GetUser(ctx context.Context, name string) (*User, bool) {
result, err := b.pool.Search(ctx, b.searchRequest(name))
if err != nil {
log.Printf("LDAP error: %v", err)
return nil, false
}
return serviceConfig.userFromResponse(name, result)
return b.userFromResponse(name, result)
}
var hex = "0123456789abcdef"
......
package server
import (
"fmt"
"strings"
"sync"
"time"
"git.autistici.org/id/auth"
)
// Try to use as little memory as possible for each entry: use a UNIX
......@@ -143,3 +147,115 @@ func (b *Blacklist) Incr(key string) {
}
b.r.mx.Unlock()
}
// Function that extracts a key from a request.
type ratelimitKeyFunc func(*User, *auth.Request) string
// Extract the username from the request.
func usernameKey(user *User, _ *auth.Request) string {
return user.Name
}
// Extract the client IP address (if present) from the request.
func ipAddrKey(_ *User, req *auth.Request) string {
if req.DeviceInfo != nil {
return req.DeviceInfo.RemoteAddr
}
return ""
}
// Configuration for a rate limiter or blacklist (depending on whether
// BlacklistTime is set or not). All times are specified in seconds.
type authRatelimiterConfig struct {
Limit int `yaml:"limit"`
Period int `yaml:"period"`
BlacklistTime int `yaml:"blacklist_for"`
OnFailure bool `yaml:"on_failure"`
Keys []string `yaml:"keys"`
}
func (r *authRatelimiterConfig) keyFunctions() ([]ratelimitKeyFunc, error) {
var keyFuncs []ratelimitKeyFunc
for _, k := range r.Keys {
var f ratelimitKeyFunc
switch k {
case "ip":
f = ipAddrKey
case "user":
f = usernameKey
default:
return nil, fmt.Errorf("unknown key %s", k)
}
keyFuncs = append(keyFuncs, f)
}
return keyFuncs, nil
}
const rlKeySep = ";"
type authRatelimiterBase struct {
keyFuncs []ratelimitKeyFunc
}
func (r *authRatelimiterBase) key(user *User, req *auth.Request) string {
if len(r.keyFuncs) == 1 {
return r.keyFuncs[0](user, req)
}
var parts []string
for _, f := range r.keyFuncs {
parts = append(parts, f(user, req))
}
return strings.Join(parts, rlKeySep)
}
// Request-oriented ratelimiter with configurable keys.
type authRatelimiter struct {
*authRatelimiterBase
rl *Ratelimiter
}
func newAuthRatelimiter(config *authRatelimiterConfig) (*authRatelimiter, error) {
kf, err := config.keyFunctions()
if err != nil {
return nil, err
}
return &authRatelimiter{
authRatelimiterBase: &authRatelimiterBase{keyFuncs: kf},
rl: newRatelimiter(config.Limit, config.Period),
}, nil
}
func (r *authRatelimiter) AllowIncr(user *User, req *auth.Request) bool {
return r.rl.AllowIncr(r.key(user, req))
}
// Request-oriented blacklist with configurable keys.
type authBlacklist struct {
*authRatelimiterBase
bl *Blacklist
onFailure bool
}
func newAuthBlacklist(config *authRatelimiterConfig) (*authBlacklist, error) {
kf, err := config.keyFunctions()
if err != nil {
return nil, err
}
return &authBlacklist{
authRatelimiterBase: &authRatelimiterBase{keyFuncs: kf},
bl: newBlacklist(config.Limit, config.Period, config.BlacklistTime),
onFailure: config.OnFailure,
}, nil
}
func (b *authBlacklist) Allow(user *User, req *auth.Request) bool {
return b.bl.Allow(b.key(user, req))
}
func (b *authBlacklist) Incr(user *User, req *auth.Request, resp *auth.Response) {
if b.onFailure && resp.Status == auth.StatusOK {
return
}
b.bl.Incr(b.key(user, req))
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment