Select Git revision
actions_user.go 13.85 KiB
package accountserver
import (
"errors"
"strings"
ct "git.autistici.org/ai3/go-common/ldap/compositetypes"
umdb "git.autistici.org/id/usermetadb"
)
// GetUserRequest retrieves a specific User.
type GetUserRequest struct {
UserRequestBase
// Whether to return an inactive user.
IncludeInactive bool `json:"include_inactive"`
}
// Serve the request.
func (r *GetUserRequest) Serve(rctx *RequestContext) (interface{}, error) {
if !r.IncludeInactive && rctx.User.Status != UserStatusActive {
return nil, ErrUserNotFound
}
// Return the public User object contained within the RawUser.
return &rctx.User.User, nil
}
// SearchUserRequest searches the database for users with names
// matching a given pattern. The actual pattern semantics are
// backend-specific (for LDAP, this is a prefix string search).
type SearchUserRequest struct {
AdminRequestBase
Pattern string `json:"pattern"`
Limit int `json:"limit"`
}
// Validate the request.
func (r *SearchUserRequest) Validate(rctx *RequestContext) error {
if r.Pattern == "" {
return newValidationError(nil, "pattern", "empty pattern")
}
return nil
}
// SearchUserResponse is the response type for SearchUserRequest.
type SearchUserResponse struct {
Usernames []string `json:"usernames"`
}
// Serve the request.
func (r *SearchUserRequest) Serve(rctx *RequestContext) (interface{}, error) {
usernames, err := rctx.TX.SearchUser(rctx.Context, r.Pattern, r.Limit)
if err != nil {
return nil, err
}
return &SearchUserResponse{Usernames: usernames}, nil
}
// ChangeUserPasswordRequest updates a user's password. It will also take
// care of re-encrypting the user encryption key, if present.
type ChangeUserPasswordRequest struct {
PrivilegedRequestBase
Password string `json:"password"`
}
// Sanitize the request.
func (r *ChangeUserPasswordRequest) Sanitize() {
r.PrivilegedRequestBase.Sanitize()
if r.Password != "" {
r.Password = sanitizedValue
}
}
// Validate the request.
func (r *ChangeUserPasswordRequest) Validate(rctx *RequestContext) error {
if err := rctx.fieldValidators.password(rctx, r.Password); err != nil {
return newValidationError(nil, "password", err.Error())
}
if r.Password == r.CurPassword {
return newValidationError(nil, "password", "The new password can't be the same as the old one")
}
return r.PrivilegedRequestBase.Validate(rctx)
}
// Serve the request.
func (r *ChangeUserPasswordRequest) Serve(rctx *RequestContext) (interface{}, error) {
err := rctx.User.setPrimaryPassword(rctx.Context, rctx.TX, r.CurPassword, r.Password, rctx.enableOpportunisticEncryption)
if err != nil {
return nil, err
}
rctx.audit.Log(rctx, nil, "password changed (user)")
rctx.logUserAction(&rctx.User.User, umdb.LogTypePasswordChange, "password changed (user)")
return nil, err
}
// AccountRecoveryRequest lets users reset their password by providing
// secondary credentials, which we authenticate ourselves. It is not
// authenticated with SSO.
//
// Two-factor authentication is disabled on successful recovery.
type AccountRecoveryRequest struct {
Username string `json:"username"`
RecoveryPassword string `json:"recovery_password"`
Password string `json:"password"`
RemoteAddr string `json:"remote_addr"`
// TODO: add full DeviceInfo?
}
// AccountRecoveryResponse is the response type for AccountRecoveryRequest.
type AccountRecoveryResponse struct {
Hint string `json:"hint,omitempty"`
}
// Sanitize the request.
func (r *AccountRecoveryRequest) Sanitize() {
if r.RecoveryPassword != "" {
r.RecoveryPassword = sanitizedValue
}
if r.Password != "" {
r.Password = sanitizedValue
}
}
// Validate the request.
func (r *AccountRecoveryRequest) Validate(rctx *RequestContext) error {
// Only validate the password if attempting recovery.
if r.RecoveryPassword != "" {
if err := rctx.fieldValidators.password(rctx, r.Password); err != nil {
return newValidationError(nil, "password", err.Error())
}
}
return nil
}
// PopulateContext extracts information from the request and stores it
// into the RequestContext.
func (r *AccountRecoveryRequest) PopulateContext(rctx *RequestContext) error {
user, err := getUserOrDie(rctx.Context, rctx.TX, r.Username)
if err != nil {
return err
}
rctx.User = user
return nil
}
// Authorize the request.
func (r *AccountRecoveryRequest) Authorize(rctx *RequestContext) error {
// The user must be in the 'active' state.
if rctx.User.Status != UserStatusActive {
return errors.New("user is not active")
}
// Anyone can request the hint (rate-limit above this layer).
if r.RecoveryPassword == "" {
return nil
}
// Authenticate the secret recovery password.
if err := rctx.authorizeAccountRecovery(rctx.Context, rctx.User.Name, r.RecoveryPassword, r.RemoteAddr); err != nil {
return err
}
return nil
}
// Serve the request.
func (r *AccountRecoveryRequest) Serve(rctx *RequestContext) (interface{}, error) {
resp := AccountRecoveryResponse{
Hint: rctx.User.AccountRecoveryHint,
}
// Only attempt to authenticate if the recovery password is
// set in the request, otherwise just return the hint.
if r.RecoveryPassword == "" {
return &resp, nil
}
if err := rctx.User.setPrimaryPassword(rctx.Context, rctx.TX, r.RecoveryPassword, r.Password, rctx.enableOpportunisticEncryption); err != nil {
return nil, err
}
if err := rctx.User.disable2FA(rctx.Context, rctx.TX); err != nil {
return nil, err
}
rctx.audit.Log(rctx, nil, "password changed (account recovery)")
rctx.logUserAction(&rctx.User.User, umdb.LogTypePasswordReset, "password changed (account recovery)")
return nil, nil
}
// ResetPasswordRequest is an admin operation to forcefully reset the
// password for an account. A new password will be randomly generated
// by the accountserver. The user will lose access to all stored email
// (because the encryption keys will be reset) and to 2FA.
type ResetPasswordRequest struct {
AdminUserRequestBase
}
// ResetPasswordResponse is the response type for ResetPasswordRequest.
type ResetPasswordResponse struct {
Password string `json:"password"`
}
// Sanitize the response.
func (r *ResetPasswordResponse) Sanitize() {
if r.Password != "" {
r.Password = sanitizedValue
}
}
// Serve the request.
func (r *ResetPasswordRequest) Serve(rctx *RequestContext) (interface{}, error) {
password := randomPassword()
if err := rctx.User.resetPassword(rctx.Context, rctx.TX, password); err != nil {
return nil, err
}
if err := rctx.User.disable2FA(rctx.Context, rctx.TX); err != nil {
return nil, err
}
rctx.audit.Log(rctx, nil, "password reset (admin)")
rctx.logUserAction(&rctx.User.User, umdb.LogTypePasswordReset, "password reset (admin)")
return &ResetPasswordResponse{
Password: password,
}, nil
}
// SetAccountRecoveryHintRequest lets users set the password recovery hint
// and expected response (secondary password).
type SetAccountRecoveryHintRequest struct {
PrivilegedRequestBase
Hint string `json:"recovery_hint"`
Response string `json:"recovery_response"`
}
// Sanitize the request.
func (r *SetAccountRecoveryHintRequest) Sanitize() {
r.PrivilegedRequestBase.Sanitize()
if r.Response != "" {
r.Response = sanitizedValue
}
}
// Validate the request.
func (r *SetAccountRecoveryHintRequest) Validate(rctx *RequestContext) error {
var err *ValidationError
if r.Hint == "" {
err = newValidationError(err, "recovery_hint", "mandatory field")
}
if verr := rctx.fieldValidators.password(rctx, r.Response); verr != nil {
err = newValidationError(err, "recovery_response", verr.Error())
}
return err.orNil()
}
// Serve the request.
func (r *SetAccountRecoveryHintRequest) Serve(rctx *RequestContext) (interface{}, error) {
return nil, rctx.User.setAccountRecoveryHint(rctx.Context, rctx.TX, r.CurPassword, r.Hint, r.Response)
}
// CreateApplicationSpecificPasswordRequest lets users create their own ASPs.
type CreateApplicationSpecificPasswordRequest struct {
PrivilegedRequestBase
Service string `json:"service"`
Notes string `json:"notes"`
}
// CreateApplicationSpecificPasswordResponse is the response type for
// CreateApplicationSpecificPasswordRequest.
type CreateApplicationSpecificPasswordResponse struct {
Password string `json:"password"`
}
// Sanitize the response.
func (r *CreateApplicationSpecificPasswordResponse) Sanitize() {
if r.Password != "" {
r.Password = sanitizedValue
}
}
// Validate the request.
func (r *CreateApplicationSpecificPasswordRequest) Validate(_ *RequestContext) error {
var err *ValidationError
if r.Service == "" {
err = newValidationError(err, "service", "mandatory field")
}
return err.orNil()
}
// Serve the request.
func (r *CreateApplicationSpecificPasswordRequest) Serve(rctx *RequestContext) (interface{}, error) {
if !rctx.User.Has2FA {
return nil, errors.New("2FA is not enabled for this user")
}
// Create a new application-specific password metadata.
asp := &ct.AppSpecificPassword{
ID: randomAppSpecificPasswordID(),
Service: r.Service,
Comment: r.Notes,
}
pw := randomAppSpecificPassword()
if err := rctx.User.addApplicationSpecificPassword(rctx.Context, rctx.TX, r.CurPassword, pw, asp); err != nil {
return nil, err
}
return &CreateApplicationSpecificPasswordResponse{
Password: pw,
}, nil
}
// DeleteApplicationSpecificPasswordRequest deletes an application-specific
// password, identified by its unique ID.
type DeleteApplicationSpecificPasswordRequest struct {
UserRequestBase
AspID string `json:"asp_id"`
}
// Serve the request.
func (r *DeleteApplicationSpecificPasswordRequest) Serve(rctx *RequestContext) (interface{}, error) {
return nil, rctx.User.deleteApplicationSpecificPassword(rctx.Context, rctx.TX, r.AspID)
}
// EnableOTPRequest enables OTP-based two-factor authentication for a
// user. The caller can generate the TOTP secret itself if needed (useful for
// UX that confirms that the user is able to login first), or it can let the
// server generate a new secret by passing an empty totp_secret.
type EnableOTPRequest struct {
UserRequestBase
TOTPSecret string `json:"totp_secret"`
}
// Sanitize the request.
func (r *EnableOTPRequest) Sanitize() {
r.UserRequestBase.Sanitize()
if r.TOTPSecret != "" {
r.TOTPSecret = sanitizedValue
}
}
// Validate the request.
func (r *EnableOTPRequest) Validate(_ *RequestContext) error {
var err *ValidationError
// Only check if the client-side secret is set, skip otherwise. We
// don't really expect a bad value coming from the generator, so the
// length check is just for internal consistency.
if r.TOTPSecret != "" && len(r.TOTPSecret) < 10 {
err = newValidationError(err, "totp_secret", "bad value")
}
return err.orNil()
}
// EnableOTPResponse is the response type for AccountService.EnableOTP().
type EnableOTPResponse struct {
TOTPSecret string `json:"totp_secret"`
}
// Sanitize the response.
func (r *EnableOTPResponse) Sanitize() {
if r.TOTPSecret != "" {
r.TOTPSecret = sanitizedValue
}
}
// Serve the request.
func (r *EnableOTPRequest) Serve(rctx *RequestContext) (interface{}, error) {
// Replace or initialize the TOTP secret.
secret := r.TOTPSecret
if secret == "" {
var err error
secret, err = generateTOTPSecret()
if err != nil {
return nil, err
}
}
if err := rctx.User.setTOTPSecret(rctx.Context, rctx.TX, secret); err != nil {
return nil, err
}
rctx.audit.Log(rctx, nil, "totp enabled")
return &EnableOTPResponse{
TOTPSecret: secret,
}, nil
}
// DisableOTPRequest disables two-factor authentication for a user.
type DisableOTPRequest struct {
UserRequestBase
}
// Serve the request.
func (r *DisableOTPRequest) Serve(rctx *RequestContext) (interface{}, error) {
if err := rctx.User.disableOTP(rctx.Context, rctx.TX); err != nil {
return nil, err
}
rctx.audit.Log(rctx, nil, "totp disabled")
return nil, nil
}
// UpdateUserRequest allows the caller to update a (very limited) selected set
// of fields on a User object. It is a catch-all function for very simple
// changes that don't justify their own specialized method. Fields are
// associated with a "set_field" attribute to allow for selective updates.
type UpdateUserRequest struct {
UserRequestBase
Lang string `json:"lang,omitempty"`
SetLang bool `json:"set_lang"`
U2FRegistrations []*ct.U2FRegistration `json:"u2f_registrations,omitempty"`
SetU2FRegistrations bool `json:"set_u2f_registrations"`
}
const maxU2FRegistrations = 20
// Validate the request.
func (r *UpdateUserRequest) Validate(rctx *RequestContext) error {
if r.SetU2FRegistrations && len(r.U2FRegistrations) > maxU2FRegistrations {
return newValidationError(nil, "u2f_registrations", "too many U2F registrations")
}
// TODO: better validation of the language code!
if r.SetLang && (r.Lang == "" || len(r.Lang) > 2) {
return newValidationError(nil, "lang", "invalid language code")
}
return nil
}
// Serve the request.
func (r *UpdateUserRequest) Serve(rctx *RequestContext) (interface{}, error) {
if r.SetLang {
rctx.User.Lang = strings.ToLower(r.Lang)
}
if r.SetU2FRegistrations {
rctx.User.U2FRegistrations = r.U2FRegistrations
if err := rctx.User.check2FAState(rctx.Context, rctx.TX); err != nil {
return nil, err
}
}
return nil, rctx.TX.UpdateUser(rctx.Context, &rctx.User.User)
}
// AdminUpdateUserRequest is the privileged version of UpdateUser and
// allows to update privileged attributes. It is a catch-all function
// for very simple changes that don't justify their own specialized
// method.
type AdminUpdateUserRequest struct {
AdminUserRequestBase
Lang string `json:"lang,omitempty"`
Status string `json:"status"`
}
// Validate the request.
func (r *AdminUpdateUserRequest) Validate(rctx *RequestContext) error {
switch r.Status {
case "", ResourceStatusActive, ResourceStatusInactive:
default:
return newValidationError(nil, "status", "invalid or unknown status")
}
return nil
}
// Serve the request.
func (r *AdminUpdateUserRequest) Serve(rctx *RequestContext) (interface{}, error) {
if r.Status != "" {
rctx.User.Status = r.Status
}
return nil, rctx.TX.UpdateUser(rctx.Context, &rctx.User.User)
}