Add a password policy for distillery users

This commit is contained in:
Tom Wiesing 2023-01-10 11:10:38 +01:00
parent ab9998881b
commit 6f257bd27f
No known key found for this signature in database
9 changed files with 185 additions and 76 deletions

View file

@ -135,6 +135,11 @@ func (du disUser) runDelete(context wisski_distillery.Context) error {
return user.Delete(context.Context) return user.Delete(context.Context)
} }
var errPasswordPolicy = exit.Error{
Message: "password policy failed: %s",
ExitCode: exit.ExitGeneric,
}
func (du disUser) runSetPassword(context wisski_distillery.Context) error { func (du disUser) runSetPassword(context wisski_distillery.Context) error {
user, err := context.Environment.Auth().User(context.Context, du.Positionals.User) user, err := context.Environment.Auth().User(context.Context, du.Positionals.User)
if err != nil { if err != nil {
@ -160,8 +165,8 @@ func (du disUser) runSetPassword(context wisski_distillery.Context) error {
if passwd != passwd1 { if passwd != passwd1 {
return errPasswordsNotIdentical return errPasswordsNotIdentical
} }
if len(passwd) == 0 { if err := user.CheckPasswordPolicy(passwd); err != nil {
return errPasswordsNotIdentical return errPasswordPolicy.WithMessageF(err)
} }
} }

View file

@ -18,7 +18,6 @@ var passwordTemplate = static.AssetsUser.MustParseShared("password.html", passwo
var ( var (
errPasswordsNotIdentical = errors.New("passwords are not identical") errPasswordsNotIdentical = errors.New("passwords are not identical")
errPasswordIsEmpty = errors.New("password is empty")
errCredentialsIncorrect = errors.New("credentials are not correct") errCredentialsIncorrect = errors.New("credentials are not correct")
errPasswordSetFailure = errors.New("error saving new password") errPasswordSetFailure = errors.New("error saving new password")
errTOTPSetFailure = errors.New("unable to disable totp") errTOTPSetFailure = errors.New("unable to disable totp")
@ -47,10 +46,6 @@ func (panel *UserPanel) routePassword(ctx context.Context) http.Handler {
return struct{}{}, errPasswordsNotIdentical return struct{}{}, errPasswordsNotIdentical
} }
if new == "" {
return struct{}{}, errPasswordIsEmpty
}
user, err := panel.Dependencies.Auth.UserOf(r) user, err := panel.Dependencies.Auth.UserOf(r)
if err != nil { if err != nil {
return struct{}{}, err return struct{}{}, err
@ -62,6 +57,14 @@ func (panel *UserPanel) routePassword(ctx context.Context) http.Handler {
return struct{}{}, errCredentialsIncorrect return struct{}{}, errCredentialsIncorrect
} }
} }
{
err := user.CheckPasswordPolicy(new)
if err != nil {
return struct{}{}, err
}
}
{ {
err := user.SetPassword(r.Context(), []byte(new)) err := user.SetPassword(r.Context(), []byte(new))
if err != nil { if err != nil {

View file

@ -6,9 +6,11 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"image/png" "image/png"
"strings"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component" "github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/models" "github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/pkg/password"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/pquerna/otp" "github.com/pquerna/otp"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
@ -232,9 +234,49 @@ func (au *AuthUser) UnsetPassword(ctx context.Context) error {
return au.Save(ctx) return au.Save(ctx)
} }
var ErrNoUser = errors.New("user is nil") const MinPasswordLength = 8
var ErrUserDisabled = errors.New("user is disabled")
var ErrUserBlank = errors.New("user has no password set") var (
ErrPolicyBlank = errors.New("password is blank")
ErrPolicyTooShort = errors.New(fmt.Sprintf("password is too short: minimum length %d", MinPasswordLength))
ErrPolicyKnown = errors.New("password is on the list of known passwords")
ErrPolicyUsername = errors.New("password may not be identical to username")
)
// CheckPasswordPolicy checks if the given password would pass the password policy.
//
// The password policy checks that the password has a minimum length of [MinPasswordLength]
// and that it is not a common password.
// It also checks that password and username are not identical.
func (auth *Auth) CheckPasswordPolicy(candidate string, username string) error {
if candidate == "" {
return ErrPolicyBlank
}
if strings.EqualFold(candidate, username) {
return ErrPolicyUsername
}
if len(candidate) < MinPasswordLength {
return ErrPolicyTooShort
}
if err := password.CheckCommonPassword(func(common string) (bool, error) { return common == candidate, nil }); err != nil {
return ErrPolicyKnown
}
return nil
}
func (au *AuthUser) CheckPasswordPolicy(candidate string) error {
return au.auth.CheckPasswordPolicy(candidate, au.User.User)
}
var (
ErrNoUser = errors.New("user is nil")
ErrUserDisabled = errors.New("user is disabled")
ErrUserBlank = errors.New("user has no password set")
)
// CheckPassword checks if this user can login with the provided password. // CheckPassword checks if this user can login with the provided password.
// Returns nil on success, an error otherwise. // Returns nil on success, an error otherwise.

View file

@ -14,9 +14,20 @@
{{ end }} {{ end }}
{{ define "content" }} {{ define "content" }}
<div class="pure-u-1"> <div class="pure-u-1">
<div class="padding"> <div class="padding">
<div class="overflow"> <div class="overflow">
{{ $E := .Error }}
{{ if not (eq $E "") }}
<div class="pure-form-group">
<p class="error-message">
{{ $E }}
</p>
</div>
{{ end }}
<table class="pure-table pure-table-bordered"> <table class="pure-table pure-table-bordered">
<thead> <thead>
<tr> <tr>

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"net/url"
_ "embed" _ "embed"
@ -24,14 +25,15 @@ var userTemplate = static.AssetsAdmin.MustParseShared(
type userContext struct { type userContext struct {
custom.BaseContext custom.BaseContext
httpx.FormContext
Error string
Users []*auth.AuthUser Users []*auth.AuthUser
} }
func (admin *Admin) users(r *http.Request) (uc userContext, err error) { func (admin *Admin) users(r *http.Request) (uc userContext, err error) {
admin.Dependencies.Custom.Update(&uc, r) admin.Dependencies.Custom.Update(&uc, r)
uc.Error = r.URL.Query().Get("error")
uc.Users, err = admin.Dependencies.Auth.Users(r.Context()) uc.Users, err = admin.Dependencies.Auth.Users(r.Context())
return return
} }
@ -78,6 +80,12 @@ func (admin *Admin) createUser(ctx context.Context) http.Handler {
return cu, errCreateInvalidPassword return cu, errCreateInvalidPassword
} }
// check the password policy
err = admin.Dependencies.Auth.CheckPasswordPolicy(cu.Passsword, cu.User)
if err != nil {
return cu, err
}
return cu, nil return cu, nil
}, },
@ -144,7 +152,7 @@ func (admin *Admin) useraction(ctx context.Context, name string, action func(r *
if err := action(r, user); err != nil { if err := action(r, user); err != nil {
logger.Err(err).Str("action", name).Msg("failed to act on user") logger.Err(err).Str("action", name).Msg("failed to act on user")
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) http.Redirect(w, r, "/admin/users/?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return return
} }
@ -185,6 +193,11 @@ func (admin *Admin) usersPasswordHandler(ctx context.Context) http.Handler {
if password == "" { if password == "" {
return httpx.ErrBadRequest return httpx.ErrBadRequest
} }
// check the password policy
err := user.CheckPasswordPolicy(password)
if err != nil {
return err
}
return user.SetPassword(r.Context(), []byte(password)) return user.SetPassword(r.Context(), []byte(password))
}) })
} }

View file

@ -1,16 +1,13 @@
package users package users
import ( import (
"bufio"
"context" "context"
"embed"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"strings"
"github.com/FAU-CDI/wisski-distillery/internal/phpx" "github.com/FAU-CDI/wisski-distillery/internal/phpx"
"github.com/FAU-CDI/wisski-distillery/pkg/password"
) )
var errGetValidator = errors.New("GetPasswordValidator: Unknown Error") var errGetValidator = errors.New("GetPasswordValidator: Unknown Error")
@ -57,14 +54,6 @@ func (pv PasswordValidator) Check(ctx context.Context, password string) bool {
var errPasswordUsername = errors.New("username === password") var errPasswordUsername = errors.New("username === password")
type CommonPasswordError struct {
Password CommonPassword
}
func (cpe CommonPasswordError) Error() string {
return fmt.Sprintf("%q from %q", cpe.Password.Password, cpe.Password.Source)
}
func (pv PasswordValidator) CheckDictionary(ctx context.Context, writer io.Writer) error { func (pv PasswordValidator) CheckDictionary(ctx context.Context, writer io.Writer) error {
var counter int var counter int
@ -75,7 +64,7 @@ func (pv PasswordValidator) CheckDictionary(ctx context.Context, writer io.Write
} }
return errPasswordUsername return errPasswordUsername
} }
for candidate := range CommonPasswords() { for candidate := range password.CommonPasswords() {
if ctx.Err() != nil { if ctx.Err() != nil {
continue continue
} }
@ -86,59 +75,9 @@ func (pv PasswordValidator) CheckDictionary(ctx context.Context, writer io.Write
} }
if result { if result {
return &CommonPasswordError{Password: candidate} return &password.CommonPasswordError{CommonPassword: candidate}
} }
} }
return ctx.Err() return ctx.Err()
} }
//go:embed passwords
var passwordsEmbed embed.FS
type CommonPassword struct {
Password string
Source string
}
// CommonPasswords returns a channel of most common passwords
func CommonPasswords() <-chan CommonPassword {
pChan := make(chan CommonPassword, 10)
go func() {
defer close(pChan)
fs.WalkDir(passwordsEmbed, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// get the full path
if d.IsDir() || !strings.HasSuffix(path, ".txt") {
return nil
}
// open it
file, err := passwordsEmbed.Open(path)
if err != nil {
return err
}
defer file.Close()
// scan it line by line
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "//") {
continue
}
pChan <- CommonPassword{
Password: line,
Source: path,
}
}
return scanner.Err()
})
}()
return pChan
}

96
pkg/password/common.go Normal file
View file

@ -0,0 +1,96 @@
package password
import (
"bufio"
"embed"
"fmt"
"io/fs"
"strings"
)
// CommonPasswordError
type CommonPasswordError struct {
CommonPassword
}
func (cpe CommonPasswordError) Error() string {
return fmt.Sprintf("%q from %q", cpe.Password, cpe.Source)
}
type CommonPassword struct {
Password string
Source string
}
//go:embed common
var commonEmbed embed.FS
// CommonPasswords returns a channel that contains all passwords.
// The caller must drain the channel.
func CommonPasswords() <-chan CommonPassword {
pChan := make(chan CommonPassword, 10)
go func() {
defer close(pChan)
fs.WalkDir(commonEmbed, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// get the full path
if d.IsDir() || !strings.HasSuffix(path, ".txt") {
return nil
}
// open it
file, err := commonEmbed.Open(path)
if err != nil {
return err
}
defer file.Close()
// scan it line by line
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "//") {
continue
}
pChan <- CommonPassword{
Password: line,
Source: path,
}
}
return scanner.Err()
})
}()
return pChan
}
// CheckCommonPassword checks if a password is a common password.
//
// check is called with each candidate password to perform the check.
// check should return a boolean indicating if the password in question corresponds to the candidate.
//
// CheckCommonPassword returns one of three error values.
//
// - a CommonPasswordError (when a password matches a common password)
// - an error returned by check (assuming some check went wrong)
// - or nil (when a password is not a common password
func CheckCommonPassword(check func(candidate string) (bool, error)) error {
for commmon := range CommonPasswords() {
ok, err := check(commmon.Password)
if err != nil {
return err
}
// password validation passed
if ok {
return CommonPasswordError{
CommonPassword: commmon,
}
}
}
return nil
}