Select Git revision
service.go 9.69 KiB
package accountserver
import (
"context"
"errors"
"fmt"
"log"
"time"
ct "git.autistici.org/ai3/go-common/ldap/compositetypes"
"git.autistici.org/id/auth"
authclient "git.autistici.org/id/auth/client"
"git.autistici.org/id/go-sso"
umdb "git.autistici.org/id/usermetadb"
umdbc "git.autistici.org/id/usermetadb/client"
)
// Backend user database interface.
//
// We are using a transactional interface even if the actual backend
// (LDAP) does not support atomic transactions, just so it is easy to
// add more backends in the future (like SQL).
type Backend interface {
NewTransaction() (TX, error)
}
// TX represents a single transaction with the backend and offers a
// high-level data management abstraction.
//
// All methods share similar semantics: Get methods will return nil if
// the requested object is not found, and only return an error in case
// of trouble reaching the backend itself.
//
// The backend enforces strict public/private data separation by
// having Get methods return public objects (as defined in types.go),
// and using specialized methods to modify the private
// (authentication-related) attributes.
//
// The API passes around the full User object, where a simple username
// would usually suffice, because it needs to synchronize things
// between resources: this is primarily due to the coupling between
// account and email resource.
//
// We might add more sophisticated resource query methods later, as
// admin-level functionality.
//
type TX interface {
Commit(context.Context) error
GetResource(context.Context, ResourceID) (*RawResource, error)
UpdateResource(context.Context, *Resource) error
CreateResources(context.Context, *User, []*Resource) ([]*Resource, error)
SetResourcePassword(context.Context, *Resource, string) error
FindResource(context.Context, FindResourceRequest) (*RawResource, error)
HasAnyResource(context.Context, []FindResourceRequest) (bool, error)
GetUser(context.Context, string) (*RawUser, error)
UpdateUser(context.Context, *User) error
CreateUser(context.Context, *User) (*User, error)
SetUserPassword(context.Context, *User, string) error
SetAccountRecoveryHint(context.Context, *User, string, string) error
DeleteAccountRecoveryHint(context.Context, *User) error
SetUserEncryptionKeys(context.Context, *User, []*ct.EncryptedKey) error
SetUserEncryptionPublicKey(context.Context, *User, []byte) error
SetApplicationSpecificPassword(context.Context, *User, *AppSpecificPasswordInfo, string) error
DeleteApplicationSpecificPassword(context.Context, *User, string) error
SetUserTOTPSecret(context.Context, *User, string) error
DeleteUserTOTPSecret(context.Context, *User) error
// Lightweight user search (backend-specific pattern).
// Returns list of matching usernames.
SearchUser(context.Context, string) ([]string, error)
// Resource search (backend-specific pattern).
SearchResource(context.Context, string) ([]*RawResource, error)
// Resource ACL check (does not necessarily hit the database).
CanAccessResource(context.Context, string, *Resource) bool
// Return the next (or any, really) available user ID.
NextUID(context.Context) (int, error)
}
// FindResourceRequest contains parameters for searching a resource by name.
type FindResourceRequest struct {
Type string
Name string
}
// AccountService implements the business logic and high-level functionality
// of the user accounts management service. It provides common services that
// the various action handlers can use, such as validation, auditing, etc.
//
// Action handlers aren't implemented as methods on AccountService,
// instead they get access to this object via the RequestContext.
type AccountService struct {
*authService
backend Backend
audit auditLogger
umdb umdbc.Client
authClient authclient.Client
userAuthService string
accountRecoveryAuthService string
validationCtx *validationContext
fieldValidators *fieldValidators
resourceValidator *resourceValidator
userValidator UserValidatorFunc
resourceTemplates *templateContext
enableOpportunisticEncryption bool
}
// NewAccountService builds a new AccountService with the specified configuration.
func NewAccountService(backend Backend, config *Config) (*AccountService, error) {
ssoValidator, err := config.ssoValidator()
if err != nil {
return nil, err
}
var authSocket = authclient.DefaultSocketPath
if config.AuthSocket != "" {
authSocket = config.AuthSocket
}
authClient := authclient.New(authSocket)
return newAccountServiceInternal(backend, config, ssoValidator, authClient)
}
const (
defaultUserAuthService = "accountserver"
defaultAccountRecoveryAuthService = "accountserver-recovery"
)
func defaultStr(value, dflt string) string {
if value == "" {
return dflt
}
return value
}
// This function is split out from the above so we can pass stubs for
// some backends when testing.
func newAccountServiceInternal(backend Backend, config *Config, ssoValidator sso.Validator, authClient authclient.Client) (*AccountService, error) {
if err := config.compile(); err != nil {
return nil, fmt.Errorf("configuration error: %v", err)
}
s := &AccountService{
authService: newAuthService(config, ssoValidator),
backend: backend,
authClient: authClient,
userAuthService: defaultStr(config.UserAuthService, defaultUserAuthService),
accountRecoveryAuthService: defaultStr(config.AccountRecoveryAuthService, defaultAccountRecoveryAuthService),
enableOpportunisticEncryption: config.EnableOpportunisticEncryption,
}
if config.AuditLogsToSyslog {
s.audit = newSyslogAuditLogger()
} else {
s.audit = newStderrAuditLogger()
}
if config.UserMetaDB != nil {
var err error
s.umdb, err = umdbc.New(config.UserMetaDB)
if err != nil {
return nil, err
}
}
vc, err := config.validationContext(backend)
if err != nil {
return nil, err
}
s.validationCtx = vc
s.fieldValidators = newFieldValidators(vc)
s.resourceValidator = newResourceValidator(vc)
s.userValidator = vc.validUser()
s.resourceTemplates = config.templateContext()
return s, nil
}
// The authService parses identity data from a request (usually in the
// form of a SSO token), validates it, and creates an Auth object to
// go along the RequestContext.
type authService struct {
validator sso.Validator
ssoService string
ssoGroups []string
ssoAdminGroup string
}
func newAuthService(config *Config, v sso.Validator) *authService {
return &authService{
validator: v,
ssoService: config.SSO.Service,
ssoGroups: config.SSO.Groups,
ssoAdminGroup: config.SSO.AdminGroup,
}
}
func (s *authService) authFromSSO(ssoToken string) (Auth, error) {
tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
if err != nil {
return Auth{}, err
}
a := Auth{Username: tkt.User}
for _, g := range tkt.Groups {
if g == s.ssoAdminGroup {
a.IsAdmin = true
break
}
}
return a, nil
}
// Fetch a user from the backend and return an error if not found
// (instead of returning a nil User). Also, set dynamic attributes
// like resource Groups.
func getUserOrDie(ctx context.Context, tx TX, username string) (*RawUser, error) {
user, err := tx.GetUser(ctx, username)
if err != nil {
return nil, newBackendError(err)
}
if user == nil {
return nil, ErrUserNotFound
}
user.groupWebResources()
return user, nil
}
func getResourceOrDie(ctx context.Context, tx TX, id ResourceID) (*RawResource, error) {
r, err := tx.GetResource(ctx, id)
if err != nil {
return nil, newBackendError(err)
}
if r == nil {
return nil, ErrResourceNotFound
}
return r, nil
}
var umdbLogTimeout = 30 * time.Second
func (s *AccountService) logUserAction(user *User, logtype, logmsg string) {
if s.umdb == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), umdbLogTimeout)
defer cancel()
if err := s.umdb.AddLog(ctx, "", &umdb.LogEntry{
Timestamp: time.Now(),
Username: user.Name,
Type: logtype,
Message: logmsg,
Service: "accountserver",
}); err != nil {
log.Printf("usermetadb.AddLog error for %s: %v", user.Name, err)
}
}()
}
// Handle the given Request. Returns its result.
func (s *AccountService) Handle(ctx context.Context, r Request) (interface{}, error) {
tx, err := s.backend.NewTransaction()
if err != nil {
return nil, err
}
rctx := &RequestContext{
AccountService: s,
Context: ctx,
TX: tx,
}
// Populate the context.
if err = r.PopulateContext(rctx); err != nil {
return nil, err
}
// Validate the request.
if err = r.Validate(rctx); err != nil {
return nil, newRequestError(err)
}
// Authorize the request.
if err = r.Authorize(rctx); err != nil {
return nil, newAuthError(err)
}
// Perform the actions.
resp, err := r.Serve(rctx)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return resp, nil
}
func (s *AccountService) callAuthServer(ctx context.Context, service, username, password, remoteAddr string) error {
resp, err := s.authClient.Authenticate(ctx, &auth.Request{
Service: service,
Username: username,
Password: []byte(password),
DeviceInfo: &auth.DeviceInfo{
RemoteAddr: remoteAddr,
},
})
if err != nil {
return err
}
if resp.Status != auth.StatusOK {
return errors.New("authentication failure")
}
return nil
}
func (s *AccountService) authorizeUser(ctx context.Context, username, password, remoteAddr string) error {
return s.callAuthServer(ctx, s.userAuthService, username, password, remoteAddr)
}
func (s *AccountService) authorizeAccountRecovery(ctx context.Context, username, password, remoteAddr string) error {
return s.callAuthServer(ctx, s.accountRecoveryAuthService, username, password, remoteAddr)
}