Implement user password checking

This commit is contained in:
Tom Wiesing 2022-11-25 15:06:01 +01:00
parent 8e2d2cce3e
commit 996ecb9f80
No known key found for this signature in database
25 changed files with 10762 additions and 224 deletions

View file

@ -2,6 +2,9 @@
This file contains signficant news items for the distillery.
# Automatic Password Checking (2022-11-25)
- Implemented automatic password checking
# Login using Distillery Administration (2022-11-23)
- The admin interface now allows login to individual user accounts

View file

@ -1,21 +1,28 @@
package cmd
import (
"fmt"
"io"
wisski_distillery "github.com/FAU-CDI/wisski-distillery"
"github.com/FAU-CDI/wisski-distillery/internal/cli"
wstatus "github.com/FAU-CDI/wisski-distillery/internal/status"
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
"github.com/tkw1536/goprogram/exit"
"github.com/tkw1536/goprogram/status"
)
// DrupalUser is the 'drupal_user' setting
var DrupalUser wisski_distillery.Command = duser{}
type duser struct {
Passwd bool `short:"p" long:"password" description:"reset password for user"`
Login bool `short:"l" long:"login" description:"print url to login as"`
Positionals struct {
CheckCommonPasswords bool `short:"d" long:"check-common-passwords" description:"check for most common passwords. Operates on all users concurrently."`
CheckPasswdInteractive bool `short:"c" long:"check-password" description:"interactively check user password"`
ResetPasswd bool `short:"r" long:"reset-password" description:"reset password for user"`
Login bool `short:"l" long:"login" description:"print url to login as"`
Positionals struct {
Slug string `positional-arg-name:"SLUG" required:"1-1" description:"slug of instance to manage"`
User string `positional-arg-name:"USER" description:"username to manage"`
User string `positional-arg-name:"USER" description:"username to manage. May be omitted for some actions"`
} `positional-args:"true"`
}
@ -29,6 +36,39 @@ func (duser) Description() wisski_distillery.Description {
}
}
var errNoActionSelected = exit.Error{
Message: "exactly one action must be selected",
ExitCode: exit.ExitGeneric,
}
var errUserParameter = exit.Error{
Message: "incorrect username parameter",
ExitCode: exit.ExitGeneric,
}
func (du duser) AfterParse() error {
var count int
for _, s := range []bool{
du.CheckCommonPasswords,
du.CheckPasswdInteractive,
du.ResetPasswd,
du.Login,
} {
if s {
count++
}
}
if count != 1 {
return errNoActionSelected
}
if du.CheckCommonPasswords != (du.Positionals.User == "") {
return errUserParameter
}
return nil
}
var errPasswordsNotIdentical = exit.Error{
Message: "Passwords are not identical",
ExitCode: exit.ExitGeneric,
@ -40,14 +80,21 @@ func (du duser) Run(context wisski_distillery.Context) error {
return err
}
if du.Passwd {
switch {
case du.CheckCommonPasswords:
return du.checkCommonPassword(context, instance)
case du.CheckPasswdInteractive:
return du.checkPasswordInteractive(context, instance)
case du.ResetPasswd:
return du.resetPassword(context, instance)
case du.Login:
return du.login(context, instance)
}
return du.login(context, instance)
panic("never reached")
}
func (du duser) login(context wisski_distillery.Context, instance *wisski.WissKI) error {
link, err := instance.Drush().Login(context.IOStream, du.Positionals.User)
link, err := instance.Users().Login(nil, du.Positionals.User)
if err != nil {
return err
}
@ -55,22 +102,83 @@ func (du duser) login(context wisski_distillery.Context, instance *wisski.WissKI
return nil
}
var errPasswordFound = exit.Error{
Message: "User had a dictionary password",
ExitCode: 5,
}
func (du duser) checkCommonPassword(context wisski_distillery.Context, instance *wisski.WissKI) error {
users := instance.Users()
entities, err := users.All(nil)
if err != nil {
return err
}
return status.RunErrorGroup(context.Stderr, status.Group[wstatus.User, error]{
PrefixString: func(item wstatus.User, index int) string {
return fmt.Sprintf("User[%q]: ", item.Name)
},
PrefixAlign: true,
Handler: func(user wstatus.User, index int, writer io.Writer) error {
pv, err := users.GetPasswordValidator(string(user.Name))
if err != nil {
return err
}
defer pv.Close()
return pv.CheckDictionary(context.Environment.Context(), writer)
},
}, entities)
}
func (du duser) checkPasswordInteractive(context wisski_distillery.Context, instance *wisski.WissKI) error {
validator, err := instance.Users().GetPasswordValidator(du.Positionals.User)
if err != nil {
return err
}
defer validator.Close()
for {
context.Printf("Enter a password to check:")
candidate, err := context.IOStream.ReadPassword()
if err != nil {
return err
}
context.Println()
if candidate == "" {
break
}
if validator.Check(candidate) {
context.Println("check passed")
} else {
context.Println("check did not pass")
}
}
return nil
}
func (du duser) resetPassword(context wisski_distillery.Context, instance *wisski.WissKI) error {
context.Printf("Enter new password for user %s:", du.Positionals.User)
passwd1, err := context.IOStream.ReadPassword()
if err != nil {
return err
}
context.Println()
context.Printf("Enter the same password again:")
passwd2, err := context.IOStream.ReadPassword()
if err != nil {
return err
}
context.Println()
if passwd1 != passwd2 {
return errPasswordsNotIdentical
}
return instance.Drush().ResetPassword(context.IOStream, du.Positionals.User, passwd1)
return instance.Users().SetPassword(nil, du.Positionals.User, passwd1)
}

View file

@ -98,7 +98,7 @@ func (i info) Run(context wisski_distillery.Context) error {
context.Printf("Users: (count %d)\n", len(info.Users))
for _, user := range info.Users {
context.Printf("- %s\n", user)
context.Printf("- %v\n", user)
}
return nil

7
go.mod
View file

@ -9,10 +9,11 @@ require (
github.com/feiin/sqlstring v0.3.0
github.com/gliderlabs/ssh v0.3.5
github.com/go-sql-driver/mysql v1.6.0
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/pkg/errors v0.9.1
github.com/tkw1536/goprogram v0.1.1
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d
golang.org/x/crypto v0.3.0
golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
gorm.io/driver/mysql v1.3.6
@ -26,6 +27,6 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect
golang.org/x/term v0.0.0-20220919170432-7a66f970e087 // indirect
golang.org/x/sys v0.2.0 // indirect
golang.org/x/term v0.2.0 // indirect
)

13
go.sum
View file

@ -12,6 +12,8 @@ github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY=
@ -29,8 +31,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tkw1536/goprogram v0.1.1 h1:gamK9OuRqoX2yQlA/nkgfVHHZWd/u2uUj6vJMYrYa70=
github.com/tkw1536/goprogram v0.1.1/go.mod h1:Jqs0sTMzhrAGCX3JQrlEwQ0WRWQACCvuQQkaBDp65pE=
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d h1:3qF+Z8Hkrw9sOhrFHti9TlB1Hkac1x+DNRkv0XQiFjo=
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 h1:fGZugkZk2UgYBxtpKmvub51Yno1LJDeEsRp2xGD+0gY=
golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@ -44,13 +47,13 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220919170432-7a66f970e087 h1:tPwmk4vmvVCMdr98VgL4JH+qZxPL8fqlUOHnyOM8N3w=
golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View file

@ -35,16 +35,7 @@ func ParamsFromEnv() (params Params, err error) {
params.ConfigPath = filepath.Join(params.ConfigPath, bootstrap.ConfigFile)
// generate a new context
ctx, cancel := context.WithCancel(context.Background())
params.Context = ctx
// cancel the context on an interrupt
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
go func() {
<-c
cancel()
}()
params.Context, _ = signal.NotifyContext(context.Background(), os.Interrupt)
// and return the params!
return params, nil

View file

@ -2,7 +2,6 @@ package info
import (
"net/http"
"strings"
"time"
_ "embed"
@ -12,6 +11,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
"github.com/gorilla/mux"
)
//go:embed "html/components.html"
@ -51,12 +51,8 @@ type ingredientsContext struct {
func (info *Info) ingredients(r *http.Request) (cp ingredientsContext, err error) {
cp.Time = time.Now().UTC()
// find the slug as the last component of path!
slug := strings.TrimSuffix(r.URL.Path, "/")
slug = slug[strings.LastIndex(slug, "/")+1:]
// find the instance itself!
instance, err := info.Instances.WissKI(slug)
instance, err := info.Instances.WissKI(mux.Vars(r)["slug"])
if err == instances.ErrWissKINotFound {
return cp, httpx.ErrNotFound
}

View file

@ -176,28 +176,81 @@
</div>
</div>
<div class="pure-u-1 pure-u-xl-2-5">
<div class="pure-u-1-1">
<h2 id="wisski">Users</h2>
</div>
<div class="pure-u-1">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th colspan="2">
Users
<th>
ID
</th>
<th>
Active
</th>
<th>
Name
</th>
<th>
Email
</th>
<th>
Roles
</th>
<th>
Created
</th>
<th>
Last Login
</th>
<th>
Action
</th>
</tr>
</thead>
<tbody>
{{ $slug := .Instance.Slug }}
{{ range $index, $user := .Info.Users }}
<tr>
<tr {{ if not $user.Status }}style="color:gray"{{ end }}>
<td>
<code>{{ $user }}</code>
<code>{{ $user.UID }}</code>
</td>
<td>
<small>
<button class="remote-link pure-button pure-button-action" role="link" data-action="login" data-params="{{ $slug }} {{ $user }} ">Login in new window</button>
</small>
<code>{{ $user.Status }}</code>
</td>
<td>
<code>{{ $user.Name }}</code>
</td>
<td>
{{ if $user.Mail }}
<a href="mailto:{{ $user.Mail }}">{{ $user.Mail }}</a>
{{ end }}
</td>
<td>
{{ range $role, $unuused := $user.Roles }}
<code>
{{ $role }}
</code>
{{ end }}
</td>
<td>
<code class="date">{{ $user.Created.Time.Format "2006-01-02T15:04:05Z07:00" }}</code>
</td>
<td>
<code class="date">{{ $user.Login.Time.Format "2006-01-02T15:04:05Z07:00" }}</code>
</td>
<td>
<form action="/dis/api/login" method="POST" target="_blank">
<input type="hidden" name="slug" value="{{ $slug }}">
<input type="hidden" name="user" value="{{ $user.Name }}">
<input type="submit" class="pure-button pure-button-action" value="Login in new window">
</form>
</td>
</tr>
{{ end }}

View file

@ -7,6 +7,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger"
"github.com/gorilla/mux"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
@ -27,50 +28,71 @@ type Info struct {
func (*Info) Routes() []string { return []string{"/dis/"} }
func (info *Info) Handler(route string, context context.Context, io stream.IOStream) (http.Handler, error) {
mux := http.NewServeMux()
func (info *Info) Handler(route string, context context.Context, io stream.IOStream) (handler http.Handler, err error) {
router := mux.NewRouter()
{
socket := &httpx.WebSocket{
Context: context,
Fallback: router,
Handler: info.serveSocket,
}
handler = httpx.BasicAuth(socket, "WissKI Distillery Admin", func(user, pass string) bool {
return user == info.Config.DisAdminUser && pass == info.Config.DisAdminPassword
})
}
// handle everything
mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == route {
http.Redirect(w, r, route+"/index", http.StatusTemporaryRedirect)
return
}
http.NotFound(w, r)
router.Path(route).HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, route+"/index", http.StatusTemporaryRedirect)
})
// add a handler for the index page
mux.Handle(route+"index", httpx.HTMLHandler[indexContext]{
router.Path(route + "index").Handler(httpx.HTMLHandler[indexContext]{
Handler: info.index,
Template: indexTemplate,
})
// add a handler for the component page
mux.Handle(route+"components", httpx.HTMLHandler[componentContext]{
router.Path(route + "components").Handler(httpx.HTMLHandler[componentContext]{
Handler: info.components,
Template: componentsTemplate,
})
// add a handler for the component page
mux.Handle(route+"ingredients/", httpx.HTMLHandler[ingredientsContext]{
router.Path(route + "ingredients/{slug}").Handler(httpx.HTMLHandler[ingredientsContext]{
Handler: info.ingredients,
Template: ingredientsTemplate,
})
// add a handler for the instance page
mux.Handle(route+"instance/", httpx.HTMLHandler[instanceContext]{
router.Path(route + "instance/{slug}").Handler(httpx.HTMLHandler[instanceContext]{
Handler: info.instance,
Template: instanceTemplate,
})
handler := &httpx.WebSocket{
Context: context,
Fallback: mux,
Handler: info.serveSocket,
}
router.Path(route + "api/login").Handler(httpx.ClientSideRedirect(func(r *http.Request) (string, error) {
// enforce POST
if r.Method != http.MethodPost {
return "", httpx.ErrMethodNotAllowed
}
// ensure that everyone is logged in!
return httpx.BasicAuth(handler, "WissKI Distillery Admin", func(user, pass string) bool {
return user == info.Config.DisAdminUser && pass == info.Config.DisAdminPassword
}), nil
// parse the form
if err := r.ParseForm(); err != nil {
return "", err
}
// get the instance
instance, err := info.Instances.WissKI(r.PostFormValue("slug"))
if err != nil {
return "", httpx.ErrNotFound
}
target, err := instance.Users().Login(nil, r.PostFormValue("user"))
if err != nil {
return "", err
}
return target.String(), err
}))
return
}

View file

@ -3,7 +3,6 @@ package info
import (
_ "embed"
"net/http"
"strings"
"time"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
@ -11,6 +10,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/internal/status"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/gorilla/mux"
)
//go:embed "html/instance.html"
@ -28,12 +28,8 @@ type instanceContext struct {
}
func (info *Info) instance(r *http.Request) (is instanceContext, err error) {
// find the slug as the last component of path!
slug := strings.TrimSuffix(r.URL.Path, "/")
slug = slug[strings.LastIndex(slug, "/")+1:]
// find the instance itself!
instance, err := info.Instances.WissKI(slug)
instance, err := info.Instances.WissKI(mux.Vars(r)["slug"])
if err == instances.ErrWissKINotFound {
return is, httpx.ErrNotFound
}

View file

@ -47,13 +47,6 @@ var socketInstanceActions = map[string]InstanceAction{
return instance.Drush().Cron(str)
},
},
"login": {
NumParams: 1,
HandleResult: func(_ *Info, instance *wisski.WissKI, params ...string) (any, error) {
link, err := instance.Drush().Login(stream.FromNil(), params[0])
return link, err
},
},
}
func (info *Info) serveSocket(conn httpx.WebSocketConnection) {

View file

@ -2,52 +2,29 @@ package phpx
import (
"encoding/json"
"errors"
"strconv"
"time"
)
// PHPBoolean represents a boolean php value.
// Boolean represents a boolean php value.
//
// The value can be marshaled to and from php and will behave as a PHP would behave.
//
// The value will always be marshaled as "true" or "false".
// Unmarshaling uses [Boolean].
type PHPBoolean bool
// Unmarshaling uses [AsBoolean].
type Boolean bool
func (bi PHPBoolean) MarshalJSON() ([]byte, error) {
if bi {
return []byte("true"), nil
}
return []byte("false"), nil
}
func (bi *PHPBoolean) UnmarshalJSON(data []byte) (err error) {
// unmarshal into a generic value
var value any
err = json.Unmarshal(data, &value)
if err != nil {
return err
}
// cast into a boolean
cast, ok := Boolean(value)
if !ok {
value = false
}
*bi = PHPBoolean(cast)
return nil
}
// Boolean tries to cast the given value to a boolean.
// AsBoolean tries to cast the given value to a boolean.
//
// It is able to handle any value that would be [json.Unmarshaled] from a corresponding PHP value.
// Value treates all values as the boolean true, except for the ones listed at [doc].
//
// [doc]: https://www.php.net/manual/en/language.types.boolean.php#language.types.boolean.casting
func Boolean(value any) (b bool, ok bool) {
func AsBoolean(value any) (b Boolean, ok bool) {
switch d := value.(type) {
case bool:
return d, true
return Boolean(d), true
case float64:
return d != 0, true
case string:
@ -62,13 +39,40 @@ func Boolean(value any) (b bool, ok bool) {
return true, false
}
// String tries to cast the given value to a string.
func (b Boolean) MarshalJSON() ([]byte, error) {
if b {
return []byte("true"), nil
}
return []byte("false"), nil
}
var errNotABoolean = errors.New("Boolean.UnmarshalJSON: Not an integer")
func (b *Boolean) UnmarshalJSON(data []byte) (err error) {
return UnmarshalIntermediate(b, func(a any) (Boolean, error) {
b, ok := AsBoolean(a)
if !ok {
return Boolean(false), errNotABoolean
}
return b, nil
}, data)
}
// String represents a string php value.
//
// The value can be marshaled to and from php and will behave as a PHP would behave.
//
// The value will always be marshaled as a literal string.
// Unmarshaling uses [AsString].
type String string
// AsString tries to cast the given value to a string.
//
// It is able to handle any value that would be [json.Unmarshaled] from a corresponding PHP value.
// Value casting is described at [doc].
//
// [doc]: https://www.php.net/manual/en/language.types.string.php#language.types.string.casting
func String(value any) (s string, ok bool) {
func AsString(value any) (s String, ok bool) {
switch d := value.(type) {
case bool:
if d {
@ -77,13 +81,12 @@ func String(value any) (s string, ok bool) {
return "", true
case float64:
if d == float64(int64(d)) {
return strconv.FormatInt(int64(d), 10), true
return String(strconv.FormatInt(int64(d), 10)), true
}
// TODO: not sure this is entirely correct
// and we should handle ints here!
return strconv.FormatFloat(d, 'E', 1, 64), true
return String(strconv.FormatFloat(d, 'E', 1, 64)), true
case string:
return d, true
return String(d), true
case []any, map[string]any:
return "Array", true
case nil:
@ -93,14 +96,38 @@ func String(value any) (s string, ok bool) {
return "", false
}
// Integer tries to cast the given value to an integer.
func (s String) MarshalJSON() ([]byte, error) {
return json.Marshal(string(s))
}
var errNotAString = errors.New("String.UnmarshalJSON: Not a string")
func (s *String) UnmarshalJSON(data []byte) (err error) {
return UnmarshalIntermediate(s, func(a any) (String, error) {
s, ok := AsString(a)
if !ok {
return s, errNotAString
}
return s, nil
}, data)
}
// Integer represents a boolean integer value.
//
// The value can be marshaled to and from php and will behave as a PHP would behave.
//
// The value will always be marshaled as an integer directly
// Unmarshaling uses [AsInteger].
type Integer int64
// AsInteger tries to cast the given value to an integer.
//
// It is able to handle any value that would be [json.Unmarshaled] from a corresponding PHP value.
// Value casting is described at [doc].
//
// [doc]: https://www.php.net/manual/en/language.types.integer.php#language.types.integer.casting
func Integer(value any) (i int64, ok bool) {
str, ok := String(value)
func AsInteger(value any) (i Integer, ok bool) {
str, ok := AsString(value)
if !ok {
return 0, false
}
@ -108,31 +135,62 @@ func Integer(value any) (i int64, ok bool) {
// try to parse the "leading" string, by successively cutting off parts of the tail
// once we have a valid number, return it.
for l := 0; l < len(str); l++ {
i64, err := strconv.ParseInt(str[:len(str)-l], 10, 64)
i64, err := strconv.ParseInt(string(str)[:len(str)-l], 10, 64)
if err != nil {
continue
}
return i64, true
return Integer(i64), true
}
return 0, true
}
// TimeInt represents a time value in PHP, represented as an integer
type TimeInt time.Time
func (i Integer) MarshalJSON() ([]byte, error) {
return json.Marshal(int64(i))
}
func (ts TimeInt) Time() time.Time {
var errNotAnInteger = errors.New("Integer.UnmarshalJSON: Not an integer")
func (i *Integer) UnmarshalJSON(data []byte) (err error) {
return UnmarshalIntermediate(i, func(a any) (Integer, error) {
i, ok := AsInteger(a)
if !ok {
return i, errNotAnInteger
}
return i, nil
}, data)
}
// Timestamp represents a time value in PHP, represented as an integer
type Timestamp time.Time
func (ts Timestamp) Time() time.Time {
return time.Time(ts)
}
func (ts TimeInt) MarshalJSON() ([]byte, error) {
func (ts Timestamp) MarshalJSON() ([]byte, error) {
return []byte(strconv.FormatInt(ts.Time().Unix(), 10)), nil
}
func (ts *TimeInt) UnmarshalJSON(data []byte) (err error) {
var value any
if err := json.Unmarshal(data, &value); err != nil {
func (ts *Timestamp) UnmarshalJSON(data []byte) (err error) {
return UnmarshalIntermediate(ts, func(value Integer) (Timestamp, error) {
return Timestamp(time.Unix(int64(value), 0)), nil
}, data)
}
// UnmarshalIntermediate unmarshals src into dest using an intermediate value of type I.
//
// It first unmarshals src into a new value of type I.
// It then calls parser to parse I into T.
func UnmarshalIntermediate[I, T any](dest *T, parser func(I) (T, error), src []byte) (err error) {
var temp I
err = json.Unmarshal(src, &temp)
if err != nil {
return err
}
unix, _ := Integer(value)
*ts = TimeInt(time.Unix(unix, 0))
*dest, err = parser(temp)
if err != nil {
return err
}
return nil
}

View file

@ -36,7 +36,7 @@ type WissKI struct {
NoPrefixes bool // TODO: Move this into the database
Prefixes []string // list of prefixes
Pathbuilders map[string]string // all the pathbuilders
Users []string // all the known users
Users []User // all the known users
}
// Statistics holds statistics generated by the WissKI module
@ -70,9 +70,9 @@ type BundleStatistics struct {
Count int `json:"entities"`
LastEdit phpx.TimeInt `json:"lastEdit"`
LastEdit phpx.Timestamp `json:"lastEdit"`
MainBundle phpx.PHPBoolean `json:"mainBundle"`
MainBundle phpx.Boolean `json:"mainBundle"`
} `json:"bundleStatistics"`
TotalBundles int `json:"totalBundles"`
TotalMainBundles int `json:"totalMainBundles"`

View file

@ -0,0 +1,65 @@
package status
import (
"encoding/json"
"strings"
"github.com/FAU-CDI/wisski-distillery/internal/phpx"
"golang.org/x/exp/slices"
)
// User represents a WissKI User
type User struct {
UID phpx.Integer `json:"uid,omitempty"`
Name phpx.String `json:"name,omitempty"`
Mail phpx.String `json:"mail,omitempty"`
Status phpx.Boolean `json:"status,omitempty"`
Created phpx.Timestamp `json:"created,omitempty"`
Changed phpx.Timestamp `json:"changed,omitempty"`
Access phpx.Timestamp `json:"access,omitempty"`
Login phpx.Timestamp `json:"login,omitempty"`
Roles UserRoles `json:"roles,omitempty"`
}
// UserRole represents the role of a user
type UserRole string
const (
Administrator UserRole = "administrator"
ContentEditor UserRole = "content_editor"
)
// UserRoles represents a set of user roles for a given user
type UserRoles map[UserRole]struct{}
// Has checks if the UserRole has the given role
func (ur UserRoles) Has(role UserRole) (ok bool) {
_, ok = ur[role]
return
}
func (ur UserRoles) MarshalJSON() ([]byte, error) {
roles := make([]string, len(ur))
i := 0
for r := range ur {
roles[i] = string(r)
i++
}
slices.Sort(roles) // for consistent marshaling
return json.Marshal(strings.Join(roles, ", "))
}
func (u *UserRoles) UnmarshalJSON(data []byte) error {
return phpx.UnmarshalIntermediate(u, func(s phpx.String) (UserRoles, error) {
if len(s) == 0 {
return nil, nil
}
roles := strings.Split(string(s), ", ")
uroles := make(UserRoles, len(roles))
for _, r := range roles {
uroles[UserRole(r)] = struct{}{}
}
return uroles, nil
}, data)
}

View file

@ -1,41 +0,0 @@
package drush
import (
"strings"
"github.com/alessio/shellescape"
"github.com/tkw1536/goprogram/exit"
"github.com/tkw1536/goprogram/stream"
)
var errLoginFailed = exit.Error{
Message: "Failed to login",
ExitCode: exit.ExitGeneric,
}
// Login generates a one-time login url for the given user
func (drush *Drush) Login(io stream.IOStream, user string) (string, error) {
var builder strings.Builder
url := drush.Liquid.URL().String()
command := shellescape.QuoteCommand([]string{"drush", "user:login", "--name=" + user, "--no-browser", "--uri=" + url})
code, err := drush.Barrel.Shell(io.Streams(&builder, nil, nil, 0), "-c", command)
if code != 0 || err != nil {
return "", errLoginFailed
}
return strings.TrimSpace(builder.String()), nil
}
var errSetPasswordFailed = exit.Error{
Message: "Failed to set password",
ExitCode: exit.ExitGeneric,
}
func (drush *Drush) ResetPassword(io stream.IOStream, user, password string) error {
code, err := drush.Barrel.Shell(io, "-c", "drush", "user:password", user, password)
if code != 0 || err != nil {
return errSetPasswordFailed
}
return nil
}

View file

@ -1,34 +0,0 @@
package extras
import (
_ "embed"
"github.com/FAU-CDI/wisski-distillery/internal/phpx"
"github.com/FAU-CDI/wisski-distillery/internal/status"
"github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient"
"github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient/php"
)
type Users struct {
ingredient.Base
PHP *php.PHP
}
//go:embed users.php
var usersPHP string
// All returns all known usernames
func (u *Users) All(server *phpx.Server) (users []string, err error) {
err = u.PHP.ExecScript(server, &users, usersPHP, "list_users")
return
}
func (u *Users) Fetch(flags ingredient.FetcherFlags, info *status.WissKI) (err error) {
if flags.Quick {
return
}
info.Users, _ = u.All(flags.Server)
return
}

View file

@ -1,16 +0,0 @@
<?php
use Drupal\user\Entity\User;
/** lists all the users */
function list_users() {
$usernames = [];
$users = User::loadMultiple(NULL);
foreach($users as $user){
$name = $user->get('name')->getString();
if(empty($name)) continue;
$usernames[] = $name;
}
return $usernames;
}

View file

@ -0,0 +1,144 @@
package users
import (
"bufio"
"context"
"embed"
"errors"
"fmt"
"io"
"io/fs"
"strings"
"github.com/FAU-CDI/wisski-distillery/internal/phpx"
)
var errGetValidator = errors.New("GetPasswordValidator: Unknown Error")
func (u *Users) GetPasswordValidator(username string) (pv PasswordValidator, err error) {
server := u.PHP.NewServer()
var hash string
err = u.PHP.ExecScript(server, &hash, usersPHP, "get_password_hash", username)
if err != nil {
server.Close()
return pv, err
}
if len(hash) == 0 {
server.Close()
return pv, errGetValidator
}
pv.server = server
pv.username = username
pv.hash = hash
return pv, nil
}
type PasswordValidator struct {
server *phpx.Server
username string
hash string
}
func (pv PasswordValidator) Close() error {
return pv.server.Close()
}
func (pv PasswordValidator) Check(password string) bool {
var result phpx.Boolean
err := pv.server.MarshalCall(&result, "check_password_hash", password, string(pv.hash))
if err != nil {
return false
}
return bool(result)
}
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(context context.Context, writer io.Writer) error {
var counter int
if pv.Check(pv.username) {
if writer != nil {
counter++
fmt.Fprintln(writer, counter)
}
return errPasswordUsername
}
for candidate := range CommonPasswords() {
if context.Err() != nil {
continue
}
result := pv.Check(candidate.Password)
if writer != nil {
counter++
fmt.Fprintln(writer, counter)
}
if result {
return &CommonPasswordError{Password: candidate}
}
}
return context.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
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
// This file contains a list of common WissKI Passwords
W1ssk1.

View file

@ -0,0 +1,79 @@
package users
import (
_ "embed"
"errors"
"net/url"
"github.com/FAU-CDI/wisski-distillery/internal/phpx"
"github.com/FAU-CDI/wisski-distillery/internal/status"
"github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient"
"github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient/php"
)
type Users struct {
ingredient.Base
PHP *php.PHP
}
//go:embed users.php
var usersPHP string
// All returns all known usernames
func (u *Users) All(server *phpx.Server) (users []status.User, err error) {
err = u.PHP.ExecScript(server, &users, usersPHP, "list_users")
return
}
var errLoginUnknownError = errors.New("Login: Unknown Error")
// Login generates a login link for the user with the given username
func (u *Users) Login(server *phpx.Server, username string) (dest *url.URL, err error) {
// generate a (relative) link
var path string
err = u.PHP.ExecScript(server, &path, usersPHP, "get_login_link", username)
// if something went wrong, return
if err != nil {
return nil, err
}
if path == "" {
return nil, errLoginUnknownError
}
// parse it as a url
dest, err = url.Parse(path)
if err != nil {
return nil, err
}
// and resolve the (possibly relative) reference
dest = u.URL().ResolveReference(dest)
return
}
var errSetPassword = errors.New("SetPassword: Unknown Error")
// SetPassword sets the password for a given user
func (u *Users) SetPassword(server *phpx.Server, username, password string) error {
var ok bool
err := u.PHP.ExecScript(server, &ok, usersPHP, "set_user_password", username, password)
if err != nil {
return err
}
if !ok {
return errSetPassword
}
return nil
}
func (u *Users) Fetch(flags ingredient.FetcherFlags, info *status.WissKI) (err error) {
if flags.Quick {
return
}
info.Users, _ = u.All(flags.Server)
return
}

View file

@ -0,0 +1,56 @@
<?php
use Drupal\Core\Url;
use Drupal\user\Entity\User;
/** lists all the users */
function list_users(): mixed {
$users = [];
foreach (User::loadMultiple(NULL) as $user) {
$fields = array_map(function ($field) {
return $field->getString();
}, $user->getFields());
if (empty($fields['name'])) continue;
$users[] = $fields;
}
return $users;
}
function set_user_password($name, $password): bool {
$user = user_load_by_name($name);
if (!$user) return false;
$user->setPassword($password);
$user->save();
return true;
}
function get_password_hash($name): string {
$user = user_load_by_name($name);
if (!$user) return "";
return $user->get('pass')->getString();
}
function check_password_hash($password, $hash): bool {
return \Drupal::service('password')->check($password, $hash);
}
function get_login_link($name): string {
$account = user_load_by_name($name);
if (!$account) return "";
$timestamp = \Drupal::time()->getRequestTime();
return Url::fromRoute(
'user.reset.login',
[
'uid' => $account->id(),
'timestamp' => $timestamp,
'hash' => user_pass_rehash($account, $timestamp),
],
[
'absolute' => false,
'query' => ['destination' => '/'],
'language' => \Drupal::languageManager()->getLanguage($account->getPreferredLangcode()),
]
)->toString();
}

View file

@ -15,6 +15,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient/mstore"
"github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient/php"
"github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient/php/extras"
"github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient/php/users"
"github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient/reserve"
"github.com/FAU-CDI/wisski-distillery/internal/wisski/liquid"
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
@ -61,6 +62,10 @@ func (wisski *WissKI) Drush() *drush.Drush {
return export[*drush.Drush](wisski)
}
func (wisski *WissKI) Users() *users.Users {
return export[*users.Users](wisski)
}
func (wisski *WissKI) Prefixes() *extras.Prefixes {
return export[*extras.Prefixes](wisski)
}
@ -99,8 +104,8 @@ func (wisski *WissKI) allIngredients() []initFunc {
auto[*extras.Prefixes],
auto[*extras.Settings],
auto[*extras.Pathbuilder],
auto[*extras.Users],
auto[*extras.Stats],
auto[*users.Users],
// info
manual(func(info *info.Info) {

View file

@ -49,8 +49,9 @@ func StatusInterceptor(contentType string, body func(code int, text string) ([]b
return ErrInterceptor{
Errors: map[error]Response{
ErrNotFound: makeResponse(http.StatusNotFound),
ErrForbidden: makeResponse(http.StatusForbidden),
ErrNotFound: makeResponse(http.StatusNotFound),
ErrForbidden: makeResponse(http.StatusForbidden),
ErrMethodNotAllowed: makeResponse(http.StatusMethodNotAllowed),
},
Fallback: makeResponse(http.StatusInternalServerError),
}
@ -58,8 +59,9 @@ func StatusInterceptor(contentType string, body func(code int, text string) ([]b
// Common errors accepted by all httpx handlers
var (
ErrNotFound = errors.New("httpx: Not Found")
ErrForbidden = errors.New("httpx: Forbidden")
ErrNotFound = errors.New("httpx: Not Found")
ErrForbidden = errors.New("httpx: Forbidden")
ErrMethodNotAllowed = errors.New("httpx: Method Not Allowed")
)
var (

View file

@ -2,6 +2,7 @@ package httpx
import (
"net/http"
"text/template"
)
// RedirectHandler represents a handler that redirects the user to the address returned
@ -20,3 +21,29 @@ func (rh RedirectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// do the redirect
http.Redirect(w, r, url, code)
}
type ClientSideRedirect func(r *http.Request) (string, error)
var htmlTemplate = template.Must(template.New("").Parse(`
<!DOCTYPE html>
<html lang="en">
<title>Redirecting</title>
<meta http-equiv="refresh" content="0; url={{ . }}" />
You should be redirected to <a href="{{ . }}">{{ . }}</a>
`))
// ServeHTTP calls r(r) and returns json
func (rh ClientSideRedirect) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// call the function
url, err := rh(r)
// intercept the errors
if htmlInterceptor.Intercept(w, r, err) {
return
}
// write out the response as json
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
htmlTemplate.Execute(w, url)
}