Commit 6639aa53 authored by ale's avatar ale

Split out the base AccountService from the business logic

parent 63c017aa
This diff is collapsed.
package accountserver
import (
"context"
"encoding/json"
"errors"
"log"
"reflect"
"git.autistici.org/id/go-sso"
)
// 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.
//
// We might add more sophisticated resource query methods later, as
// admin-level functionality.
//
type TX interface {
Commit(context.Context) error
GetResource(context.Context, ResourceID) (*Resource, error)
UpdateResource(context.Context, *Resource) error
SetResourcePassword(context.Context, *Resource, string) error
GetUser(context.Context, string) (*User, error)
SetUserPassword(context.Context, *User, string) error
GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error)
SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) 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
HasAnyResource(context.Context, []FindResourceRequest) (bool, 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.
type AccountService struct {
validator sso.Validator
ssoService string
ssoGroups []string
ssoAdminGroup string
passwordValidator ValidatorFunc
dataValidators map[string]ValidatorFunc
adminDataValidators map[string]ValidatorFunc
}
func NewAccountService(backend Backend, config *Config) (*AccountService, error) {
ssoValidator, err := config.ssoValidator()
if err != nil {
return nil, err
}
return newAccountServiceWithSSO(backend, config, ssoValidator), nil
}
func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.Validator) *AccountService {
s := &AccountService{
validator: ssoValidator,
ssoService: config.SSO.Service,
ssoGroups: config.SSO.Groups,
ssoAdminGroup: config.SSO.AdminGroup,
}
validationConfig := config.validationConfig()
domainBackend := config.domainBackend()
s.dataValidators = map[string]ValidatorFunc{
ResourceTypeEmail: validHostedEmail(validationConfig, domainBackend, backend),
ResourceTypeMailingList: validHostedMailingList(validationConfig, domainBackend, backend),
}
s.passwordValidator = validPassword(validationConfig)
return s
}
func (s *AccountService) isAdmin(tkt *sso.Ticket) bool {
for _, g := range tkt.Groups {
if g == s.ssoAdminGroup {
return true
}
}
return false
}
var (
ErrUnauthorized = errors.New("unauthorized")
ErrUserNotFound = errors.New("user not found")
ErrResourceNotFound = errors.New("resource not found")
)
func (s *AccountService) validateSSO(ssoToken string) (*sso.Ticket, error) {
return s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
}
func (s *AccountService) getUser(ctx context.Context, tx TX, username string) (*User, error) {
user, err := tx.GetUser(ctx, username)
if err != nil {
return nil, newBackendError(err)
}
if user == nil {
return nil, ErrUserNotFound
}
return user, nil
}
func (s *AccountService) getResource(ctx context.Context, tx TX, id ResourceID) (*Resource, error) {
r, err := tx.GetResource(ctx, id)
if err != nil {
return nil, newBackendError(err)
}
if r == nil {
return nil, ErrResourceNotFound
}
return r, nil
}
type authUserCtxKeyType int
var authUserCtxKey authUserCtxKeyType = 0
func authUserFromContext(ctx context.Context) string {
s, ok := ctx.Value(userCtxKey).(string)
if ok {
return s
}
return ""
}
func (s *AccountService) authorizeAdmin(ctx context.Context, tx TX, req RequestBase) (context.Context, *User, error) {
// Validate the SSO ticket.
tkt, err := s.validateSSO(req.SSO)
if err != nil {
return nil, nil, newAuthError(err)
}
// Requests are allowed if the SSO ticket corresponds to an admin, or if
// it identifies the same user that we're querying.
if !s.isAdmin(tkt) {
return nil, nil, newAuthError(ErrUnauthorized)
}
ctx = context.WithValue(ctx, authUserCtxKey, tkt.User)
user, err := s.getUser(ctx, tx, req.Username)
return ctx, user, err
}
func (s *AccountService) authorizeUser(ctx context.Context, tx TX, req RequestBase) (context.Context, *User, error) {
// First, check that the username matches the SSO ticket
// username (or that the SSO ticket has admin permissions).
tkt, err := s.validateSSO(req.SSO)
if err != nil {
return nil, nil, newAuthError(err)
}
// Requests are allowed if the SSO ticket corresponds to an admin, or if
// it identifies the same user that we're querying.
if !s.isAdmin(tkt) && tkt.User != req.Username {
return nil, nil, newAuthError(ErrUnauthorized)
}
user, err := s.getUser(ctx, tx, req.Username)
ctx = context.WithValue(ctx, authUserCtxKey, tkt.User)
return ctx, user, err
}
// Extended version of authorizeUser that also directly checks the
// user password. Used for account-privileged operations related to
// credential manipulation.
func (s *AccountService) authorizeUserWithPassword(ctx context.Context, tx TX, req PrivilegedRequestBase) (context.Context, *User, error) {
// TODO: call out to the auth-server?
return s.authorizeUser(ctx, tx, req.RequestBase)
}
// Extended version of authorizeUser that checks access to a specific
// resource (which must belong to the specified user). To be used
// wherever we take a ResourceID in the request.
//
// Note that this access control method only works for resources that
// have explicit ownership (i.e. they have the user in their resource
// ID). For shared resources like mailing lists, we will need to
// delegate the ownership check to the Resource itself.
func (s *AccountService) authorizeResource(ctx context.Context, tx TX, req ResourceRequestBase) (context.Context, *Resource, error) {
tkt, err := s.validateSSO(req.SSO)
if err != nil {
return nil, nil, newAuthError(err)
}
r, err := s.getResource(ctx, tx, req.ResourceID)
if err != nil {
return nil, nil, err
}
if !s.isAdmin(tkt) && !canAccessResource(tkt.User, r) {
return nil, nil, newAuthError(ErrUnauthorized)
}
ctx = context.WithValue(ctx, authUserCtxKey, tkt.User)
return ctx, r, nil
}
func canAccessResource(username string, r *Resource) bool {
switch r.ID.Type() {
case ResourceTypeMailingList:
// Check the list owners.
for _, a := range r.List.Admins {
if a == username {
return true
}
}
return false
default:
return r.ID.User() == username
}
}
type hasNewContext interface {
NewContext(context.Context) context.Context
}
type hasValidate interface {
Validate(context.Context, *AccountService) error
}
// Wrapper for actions that sets up some request-related parameters
// (mostly in the Context, used for later logging).
func (s *AccountService) withRequest(ctx context.Context, req interface{}, f func(context.Context) error) error {
if rnc, ok := req.(hasNewContext); ok {
ctx = rnc.NewContext(ctx)
}
if rv, ok := req.(hasValidate); ok {
if err := rv.Validate(ctx, s); err != nil {
return newRequestError(err)
}
}
err := f(ctx)
log.Printf("%s %s err=%v", reflect.TypeOf(req).String(), dumpRequest(req), err)
return err
}
func dumpRequest(req interface{}) string {
data, _ := json.Marshal(req)
return string(data)
}
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