Implement initial login functionality

This commit is contained in:
Tom Wiesing 2022-12-05 16:14:54 +01:00
parent a3bd0db78c
commit 3aa79b0d23
No known key found for this signature in database
36 changed files with 908 additions and 70 deletions

187
cmd/dis_user.go Normal file
View file

@ -0,0 +1,187 @@
package cmd
import (
wisski_distillery "github.com/FAU-CDI/wisski-distillery"
"github.com/FAU-CDI/wisski-distillery/internal/cli"
"github.com/tkw1536/goprogram/exit"
)
// DisUser is the 'dis_user' command
var DisUser wisski_distillery.Command = disUser{}
type disUser struct {
CreateUser bool `short:"c" long:"create" description:"create a new user"`
DeleteUser bool `short:"d" long:"delete" description:"delete a user"`
InfoUser bool `short:"i" long:"info" description:"show information about a user"`
ListUsers bool `short:"l" long:"list" description:"list all users"`
SetPassword bool `short:"s" long:"set-password" description:"interactively set a user password"`
UnsetPassword bool `short:"u" long:"unset-password" description:"delete a users password and block the account"`
CheckPassword bool `short:"p" long:"check-password" description:"interactively check a user password"`
Positionals struct {
User string `positional-arg-name:"USER" description:"username to manage. May be omitted for some actions"`
} `positional-args:"true"`
}
func (disUser) Description() wisski_distillery.Description {
return wisski_distillery.Description{
Requirements: cli.Requirements{
NeedsDistillery: true,
},
Command: "dis_user",
Description: "manage distillery users",
}
}
var errUserRequired = exit.Error{
Message: "`USER` argument is required",
ExitCode: exit.ExitCommandArguments,
}
func (du disUser) AfterParse() error {
var counter int
for _, action := range []bool{
du.CreateUser,
du.InfoUser,
du.DeleteUser,
du.SetPassword,
du.UnsetPassword,
du.CheckPassword,
du.ListUsers,
} {
if action {
counter++
}
}
if counter != 1 {
return errNoActionSelected
}
if !du.ListUsers && du.Positionals.User == "" {
return errUserRequired
}
return nil
}
func (du disUser) Run(context wisski_distillery.Context) error {
switch {
case du.InfoUser:
return du.runInfo(context)
case du.CreateUser:
return du.runCreate(context)
case du.DeleteUser:
return du.runDelete(context)
case du.SetPassword:
return du.runSetPassword(context)
case du.UnsetPassword:
return du.runUnsetPassword(context)
case du.CheckPassword:
return du.runCheckPassword(context)
case du.ListUsers:
return du.runListUsers(context)
}
panic("never reached")
}
func (du disUser) runInfo(context wisski_distillery.Context) error {
user, err := context.Environment.Auth().User(context.Context, du.Positionals.User)
if err != nil {
return err
}
context.Println(user)
return nil
}
func (du disUser) runCreate(context wisski_distillery.Context) error {
user, err := context.Environment.Auth().CreateUser(context.Context, du.Positionals.User)
if err != nil {
return err
}
context.Println(user)
return nil
}
func (du disUser) runDelete(context wisski_distillery.Context) error {
user, err := context.Environment.Auth().User(context.Context, du.Positionals.User)
if err != nil {
return err
}
return user.Delete(context.Context)
}
func (du disUser) runSetPassword(context wisski_distillery.Context) error {
user, err := context.Environment.Auth().User(context.Context, du.Positionals.User)
if err != nil {
return err
}
var passwd string
{
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:")
passwd, err = context.IOStream.ReadPassword()
if err != nil {
return err
}
context.Println()
if passwd != passwd1 {
return errPasswordsNotIdentical
}
if len(passwd) == 0 {
return errPasswordsNotIdentical
}
}
return user.SetPassword(context.Context, []byte(passwd))
}
func (du disUser) runUnsetPassword(context wisski_distillery.Context) error {
user, err := context.Environment.Auth().User(context.Context, du.Positionals.User)
if err != nil {
return err
}
return user.UnsetPassword(context.Context)
}
func (du disUser) runCheckPassword(context wisski_distillery.Context) error {
user, err := context.Environment.Auth().User(context.Context, du.Positionals.User)
if err != nil {
return err
}
context.Printf("Enter password for %s:", du.Positionals.User)
candidate, err := context.IOStream.ReadPassword()
if err != nil {
return err
}
context.Println()
return user.CheckPassword(context.Context, []byte(candidate))
}
func (du disUser) runListUsers(context wisski_distillery.Context) error {
users, err := context.Environment.Auth().Users(context.Context)
if err != nil {
return err
}
for _, user := range users {
context.Println(user)
}
return nil
}

View file

@ -13,9 +13,9 @@ import (
) )
// DrupalUser is the 'drupal_user' setting // DrupalUser is the 'drupal_user' setting
var DrupalUser wisski_distillery.Command = duser{} var DrupalUser wisski_distillery.Command = drupalUser{}
type duser struct { type drupalUser struct {
CheckCommonPasswords bool `short:"d" long:"check-common-passwords" description:"check for most common passwords. operates on all users concurrently."` 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"` CheckPasswdInteractive bool `short:"c" long:"check-password" description:"interactively check user password"`
ResetPasswd bool `short:"r" long:"reset-password" description:"reset password for user"` ResetPasswd bool `short:"r" long:"reset-password" description:"reset password for user"`
@ -26,7 +26,7 @@ type duser struct {
} `positional-args:"true"` } `positional-args:"true"`
} }
func (duser) Description() wisski_distillery.Description { func (drupalUser) Description() wisski_distillery.Description {
return wisski_distillery.Description{ return wisski_distillery.Description{
Requirements: cli.Requirements{ Requirements: cli.Requirements{
NeedsDistillery: true, NeedsDistillery: true,
@ -46,7 +46,7 @@ var errUserParameter = exit.Error{
ExitCode: exit.ExitGeneric, ExitCode: exit.ExitGeneric,
} }
func (du duser) AfterParse() error { func (du drupalUser) AfterParse() error {
var count int var count int
for _, s := range []bool{ for _, s := range []bool{
du.CheckCommonPasswords, du.CheckCommonPasswords,
@ -74,7 +74,7 @@ var errPasswordsNotIdentical = exit.Error{
ExitCode: exit.ExitGeneric, ExitCode: exit.ExitGeneric,
} }
func (du duser) Run(context wisski_distillery.Context) error { func (du drupalUser) Run(context wisski_distillery.Context) error {
instance, err := context.Environment.Instances().WissKI(context.Context, du.Positionals.Slug) instance, err := context.Environment.Instances().WissKI(context.Context, du.Positionals.Slug)
if err != nil { if err != nil {
return err return err
@ -93,7 +93,7 @@ func (du duser) Run(context wisski_distillery.Context) error {
panic("never reached") panic("never reached")
} }
func (du duser) login(context wisski_distillery.Context, instance *wisski.WissKI) error { func (du drupalUser) login(context wisski_distillery.Context, instance *wisski.WissKI) error {
link, err := instance.Users().Login(context.Context, nil, du.Positionals.User) link, err := instance.Users().Login(context.Context, nil, du.Positionals.User)
if err != nil { if err != nil {
return err return err
@ -107,7 +107,7 @@ var errPasswordFound = exit.Error{
ExitCode: 5, ExitCode: 5,
} }
func (du duser) checkCommonPassword(context wisski_distillery.Context, instance *wisski.WissKI) error { func (du drupalUser) checkCommonPassword(context wisski_distillery.Context, instance *wisski.WissKI) error {
users := instance.Users() users := instance.Users()
entities, err := users.All(context.Context, nil) entities, err := users.All(context.Context, nil)
@ -132,7 +132,7 @@ func (du duser) checkCommonPassword(context wisski_distillery.Context, instance
}, entities) }, entities)
} }
func (du duser) checkPasswordInteractive(context wisski_distillery.Context, instance *wisski.WissKI) error { func (du drupalUser) checkPasswordInteractive(context wisski_distillery.Context, instance *wisski.WissKI) error {
validator, err := instance.Users().GetPasswordValidator(context.Context, du.Positionals.User) validator, err := instance.Users().GetPasswordValidator(context.Context, du.Positionals.User)
if err != nil { if err != nil {
return err return err
@ -161,7 +161,7 @@ func (du duser) checkPasswordInteractive(context wisski_distillery.Context, inst
return nil return nil
} }
func (du duser) resetPassword(context wisski_distillery.Context, instance *wisski.WissKI) error { func (du drupalUser) resetPassword(context wisski_distillery.Context, instance *wisski.WissKI) error {
context.Printf("Enter new password for user %s:", du.Positionals.User) context.Printf("Enter new password for user %s:", du.Positionals.User)
passwd1, err := context.IOStream.ReadPassword() passwd1, err := context.IOStream.ReadPassword()
if err != nil { if err != nil {

View file

@ -50,6 +50,9 @@ func init() {
wdcli.Register(cmd.DrupalSetting) wdcli.Register(cmd.DrupalSetting)
wdcli.Register(cmd.DrupalUser) wdcli.Register(cmd.DrupalUser)
// distillery auth
wdcli.Register(cmd.DisUser)
// backup & cron // backup & cron
wdcli.Register(cmd.Snapshot) wdcli.Register(cmd.Snapshot)
wdcli.Register(cmd.Backup) wdcli.Register(cmd.Backup)

2
go.mod
View file

@ -10,6 +10,7 @@ require (
github.com/gliderlabs/ssh v0.3.5 github.com/gliderlabs/ssh v0.3.5
github.com/go-sql-driver/mysql v1.6.0 github.com/go-sql-driver/mysql v1.6.0
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/sessions v1.2.1
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
@ -25,6 +26,7 @@ require (
require ( require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gosuri/uilive v0.0.4 // indirect github.com/gosuri/uilive v0.0.4 // indirect
github.com/jessevdk/go-flags v1.5.0 // indirect github.com/jessevdk/go-flags v1.5.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect

4
go.sum
View file

@ -16,6 +16,10 @@ github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 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/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY= github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY=

File diff suppressed because one or more lines are too long

View file

@ -87,6 +87,9 @@ type Config struct {
DisAdminUser string `env:"DIS_ADMIN_USER" default:"admin" parser:"nonempty"` DisAdminUser string `env:"DIS_ADMIN_USER" default:"admin" parser:"nonempty"`
DisAdminPassword string `env:"DIS_ADMIN_PASSWORD" default:"" parser:"nonempty"` DisAdminPassword string `env:"DIS_ADMIN_PASSWORD" default:"" parser:"nonempty"`
// session secret holds the secret for login
SessionSecret string `env:"SESSION_SECRET" default:"" parser:"nonempty"`
// name of docker network to use // name of docker network to use
DockerNetworkName string `env:"DOCKER_NETWORK_NAME" default:"distillery" parser:"nonempty"` DockerNetworkName string `env:"DOCKER_NETWORK_NAME" default:"distillery" parser:"nonempty"`

View file

@ -69,13 +69,13 @@ GRAPHDB_ADMIN_PASSWORD=${GRAPHDB_ADMIN_PASSWORD}
MYSQL_ADMIN_USER=${MYSQL_ADMIN_USER} MYSQL_ADMIN_USER=${MYSQL_ADMIN_USER}
MYSQL_ADMIN_PASSWORD=${MYSQL_ADMIN_PASSWORD} MYSQL_ADMIN_PASSWORD=${MYSQL_ADMIN_PASSWORD}
# The admin user and password required to access the keycloak server and api
KEYCLOAK_ADMIN_USER=${KEYCLOAK_ADMIN_USER}
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD}
# The admin user and password required to access the /dis/ server and api # The admin user and password required to access the /dis/ server and api
DIS_ADMIN_USER=${DIS_ADMIN_USER} DIS_ADMIN_USER=${DIS_ADMIN_USER}
DIS_ADMIN_PASSWORD=${DIS_ADMIN_PASSWORD} DIS_ADMIN_PASSWORD=${DIS_ADMIN_PASSWORD}
# the interval to run cron in # the interval to run cron in
CRON_INTERVAL=10m CRON_INTERVAL=10m
# The secret for sessions (for login etc)
SESSION_SECRET=${SESSION_SECRET}

View file

@ -29,6 +29,7 @@ type Template struct {
DisAdminUsername string `env:"DIS_ADMIN_USER"` DisAdminUsername string `env:"DIS_ADMIN_USER"`
DisAdminPassword string `env:"DIS_ADMIN_PASSWORD"` DisAdminPassword string `env:"DIS_ADMIN_PASSWORD"`
DockerNetworkName string `env:"DOCKER_NETWORK_NAME"` DockerNetworkName string `env:"DOCKER_NETWORK_NAME"`
SessionSecret string `env:"SESSION_SECRET"`
} }
// SetDefaults sets defaults on the template // SetDefaults sets defaults on the template
@ -94,6 +95,13 @@ func (tpl *Template) SetDefaults(env environment.Environment) (err error) {
tpl.DockerNetworkName = `distillery-` + tpl.DockerNetworkName tpl.DockerNetworkName = `distillery-` + tpl.DockerNetworkName
} }
if tpl.SessionSecret == "" {
tpl.SessionSecret, err = password.Password(100)
if err != nil {
return err
}
}
return nil return nil
} }

View file

@ -18,7 +18,7 @@ func (dis *Distillery) init() {
lazy.RegisterPoolGroup[component.DistilleryFetcher](&dis.pool) lazy.RegisterPoolGroup[component.DistilleryFetcher](&dis.pool)
lazy.RegisterPoolGroup[component.Installable](&dis.pool) lazy.RegisterPoolGroup[component.Installable](&dis.pool)
lazy.RegisterPoolGroup[component.Provisionable](&dis.pool) lazy.RegisterPoolGroup[component.Provisionable](&dis.pool)
lazy.RegisterPoolGroup[component.Servable](&dis.pool) lazy.RegisterPoolGroup[component.Routeable](&dis.pool)
lazy.RegisterPoolGroup[component.Cronable](&dis.pool) lazy.RegisterPoolGroup[component.Cronable](&dis.pool)
}) })
} }

View file

@ -0,0 +1,23 @@
package auth
import (
"sync"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql"
"github.com/gorilla/sessions"
)
type Auth struct {
component.Base
Dependencies struct {
SQL *sql.SQL
}
storeOnce sync.Once
store sessions.Store
}
var (
_ component.Routeable = (*Auth)(nil)
)

View file

@ -0,0 +1,31 @@
{{ template "_base.html" . }}
{{ define "title" }}Login{{ end }}
{{ define "header/time" }}
<!-- no header/time -->
{{ end }}
{{ define "header"}}
<!-- no header -->
{{ end }}
{{ define "content" }}
<div class="pure-u-1">
{{ if .Message }}
<div>
{{ .Message }}
</div>
{{ end }}
<form class="pure-form" method="POST">
<fieldset>
<legend>Login Required</legend>
<input type="text" name="username">
<input type="password" name="password">
<input type="submit" value="Login">
</fieldset>
</form>
</div>
{{ end }}

View file

@ -0,0 +1,176 @@
package auth
import (
"context"
"errors"
"fmt"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"golang.org/x/crypto/bcrypt"
)
// ErrUserNotFound is returned when a user is not found
var ErrUserNotFound = errors.New("user not found")
// Users returns all users in the database
func (auth *Auth) Users(ctx context.Context) (users []*AuthUser, err error) {
// query the user table
table, err := auth.Dependencies.SQL.QueryTable(ctx, false, models.UserTable)
if err != nil {
return
}
// find all the users
var dUsers []models.User
err = table.Find(&dUsers).Error
if err != nil {
return nil, err
}
// and map them to high-level user objects
users = make([]*AuthUser, len(dUsers))
for i, user := range dUsers {
users[i] = &AuthUser{
User: user,
auth: auth,
}
}
return users, nil
}
// User returns a single user.
// If the user does not exist, returns ErrUserNotFound.
func (auth *Auth) User(ctx context.Context, name string) (user *AuthUser, err error) {
// quick and dirty check for the empty username (which is not allowed)
if name == "" {
return nil, ErrUserNotFound
}
// return the user
table, err := auth.Dependencies.SQL.QueryTable(ctx, false, models.UserTable)
if err != nil {
return
}
user = &AuthUser{}
// find the user
res := table.Where(&models.User{User: name}).Find(&user.User)
err = res.Error
if err != nil {
return
}
// check if the user was not found
if res.RowsAffected == 0 {
return nil, ErrUserNotFound
}
user.auth = auth
return
}
// CreateUser creates a new user and returns it.
// The user is not associated to any WissKIs, and has no password set.
func (auth *Auth) CreateUser(ctx context.Context, name string) (user *AuthUser, err error) {
// return the user
table, err := auth.Dependencies.SQL.QueryTable(ctx, false, models.UserTable)
if err != nil {
return
}
user = &AuthUser{
User: models.User{
User: name,
Enabled: false,
},
}
// do the create statement
err = table.Create(&user.User).Error
if err != nil {
return nil, err
}
user.auth = auth
return user, nil
}
// AuthUser represents an authorized user
type AuthUser struct {
auth *Auth
models.User
}
func (au AuthUser) String() string {
hasPassword := len(au.PasswordHash) > 0
return fmt.Sprintf("User{Name:%q,Enabled:%t,HasPassword:%t,Admin:%t}", au.User.User, au.User.Enabled, hasPassword, au.User.Admin)
}
// SetPassword sets the password for this user and turns the user on
func (au *AuthUser) SetPassword(ctx context.Context, password []byte) (err error) {
au.User.PasswordHash, err = bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
if err != nil {
return err
}
au.User.Enabled = true
return au.Save(ctx)
}
// UnsetPassword removes the password from this user, and disables them
func (au *AuthUser) UnsetPassword(ctx context.Context) error {
au.User.PasswordHash = nil
au.User.Enabled = false
return au.Save(ctx)
}
var ErrUserDisabled = errors.New("user is disabled")
var ErrUserBlank = errors.New("user has no password set")
// CheckPassword checks if this user can login with the provided password.
// Returns nil on success, an error otherwise.
func (au *AuthUser) CheckPassword(ctx context.Context, password []byte) error {
if !au.User.Enabled {
return ErrUserDisabled
}
if len(au.User.PasswordHash) == 0 {
return ErrUserDisabled
}
return bcrypt.CompareHashAndPassword(au.User.PasswordHash, password)
}
// MakeAdmin makes this user an admin, and saves the update in the database.
// If the user is already an admin, does not return an error.
func (au *AuthUser) MakeAdmin(ctx context.Context) error {
au.User.Admin = true
return au.Save(ctx)
}
// MakeRegular removes admin rights from this user.
// If this user is not an dmin, does not return an error.
func (au *AuthUser) MakeRegular(ctx context.Context) error {
au.User.Admin = true
return au.Save(ctx)
}
// Save saves the given user in the database
func (au *AuthUser) Save(ctx context.Context) error {
table, err := au.auth.Dependencies.SQL.QueryTable(ctx, false, models.UserTable)
if err != nil {
return err
}
return table.Save(&au.User).Error
}
// Delete deletes the user from the database
func (au *AuthUser) Delete(ctx context.Context) error {
table, err := au.auth.Dependencies.SQL.QueryTable(ctx, false, models.UserTable)
if err != nil {
return err
}
return table.Delete(&au.User).Error
}

View file

@ -0,0 +1,260 @@
package auth
import (
"context"
"net/http"
"net/url"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/gorilla/sessions"
"github.com/julienschmidt/httprouter"
_ "embed"
)
func (auth *Auth) Routes() []string {
return []string{"/auth/"}
}
type contextUserKey struct{}
var ctxUserKey = contextUserKey{}
const (
sessionCookieName = "distillery-session"
sessionUserKey = "user"
)
// session returns the session belonging to a request
func (auth *Auth) session(r *http.Request) (*sessions.Session, error) {
auth.storeOnce.Do(func() {
auth.store = sessions.NewCookieStore([]byte(auth.Config.SessionSecret))
})
return auth.store.Get(r, sessionCookieName)
}
// UserOf returns the user logged into the given request.
// If there is no user associated with the given user, user and error will be nil.
//
// When no UserOf exists in the given session returns nil.
// An invalid session (for a UserOf)
func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, err error) {
ctx := r.Context()
if user, ok := ctx.Value(ctxUserKey).(*AuthUser); ok && user != nil {
return user, nil
}
// first read the session
sess, err := auth.session(r)
if err != nil {
return nil, err
}
// try to read the name from the session
name, ok := sess.Values[sessionUserKey]
if !ok {
return nil, nil
}
nameS, ok := name.(string)
if !ok || nameS == "" {
return nil, nil
}
// fetch the user, check if they still exist
user, err = auth.User(ctx, nameS)
if err == ErrUserNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
// user isn't enabled
if !user.Enabled {
return nil, nil
}
// get the user
return user, nil
}
// writeLogin marks the user as logged in on the given writer
func (auth *Auth) writeLogin(w http.ResponseWriter, r *http.Request, user *AuthUser) error {
sess, err := auth.session(r)
if err != nil {
return err
}
sess.Values[sessionUserKey] = user.User.User
return sess.Save(r, w)
}
// writeLogout logs out the user form the given session
func (auth *Auth) writeLogout(w http.ResponseWriter, r *http.Request) error {
sess, err := auth.session(r)
if err != nil {
return err
}
sess.Options.MaxAge = -1
return sess.Save(r, w)
}
//go:embed "templates/login.html"
var loginHTMLStr string
var loginTemplate = static.AssetsAuthLogin.MustParseShared("login.html", loginHTMLStr)
var loginResponse = httpx.Response{
ContentType: "text/plain",
Body: []byte("user is signed in"),
}
// HandleRoute returns the handler for the requested route
func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
router := httprouter.New()
router.Handler(http.MethodGet, route, auth.Protect(loginResponse, nil))
router.HandlerFunc(http.MethodGet, route+"login", auth.loginRoute)
router.HandlerFunc(http.MethodPost, route+"login", auth.loginRoute)
router.HandlerFunc(http.MethodGet, route+"logout", auth.logoutRoute)
return router, nil
}
type loginContext struct {
Message string
}
// Protect returns a new handler which requires a user to be logged in and pass the perm function.
//
// If an unauthenticated user attempts to access the returned handler, they are redirected to the login endpoint.
// When a user is logged in, and they pass the perm function (or the perm function is nil), the original handler is called.
func (auth *Auth) Protect(handler http.Handler, perm func(user *AuthUser, r *http.Request) (ok bool, err error)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// load the user in the session
user, err := auth.UserOf(r)
if err != nil {
goto err
}
// if there is no user in the session
// we need to login the user
if user == nil {
// we can't redirect anything other than GET
// (because it might be a form)
// => so we just return a forbidden
if r.Method != http.MethodGet {
goto forbidden
}
// redirect the user to the login endpoint, with the original URI as a return
dest := "/auth/login?next=" + url.QueryEscape(r.URL.RequestURI())
http.Redirect(w, r, dest, http.StatusSeeOther)
return
}
// if we have a permission check, we need to call it
// to find out if the user is actually allowed to access the page
if perm != nil {
ok, err := perm(user, r)
if err != nil {
goto err
}
if !ok {
goto forbidden
}
}
// store the user into the session
r = r.WithContext(context.WithValue(r.Context(), ctxUserKey, user))
handler.ServeHTTP(w, r)
return
forbidden:
httpx.HTMLInterceptor.Intercept(w, r, httpx.ErrForbidden)
return
err:
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
})
}
func (auth *Auth) loginRoute(w http.ResponseWriter, r *http.Request) {
var message string
// try to read a user from the session
user, err := auth.UserOf(r)
if err != nil {
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
return
}
if user != nil {
goto success
}
switch r.Method {
default:
panic("never reached")
case http.MethodGet:
goto form
case http.MethodPost:
// parse the form!
if err := r.ParseForm(); err != nil {
message = "Login failed"
goto form
}
// get the username and password
username := r.Form.Get("username")
password := r.Form.Get("password")
// make sure that the user exists
user, err := auth.User(r.Context(), username)
if err != nil {
message = "Login failed"
goto form
}
// check the password (TODO: Support TOTP)
err = user.CheckPassword(r.Context(), []byte(password))
if err != nil {
message = "Login failed"
goto form
}
// and we logged the user in!
auth.writeLogin(w, r, user)
goto success
}
form:
httpx.WriteHTML(loginContext{
Message: message,
}, nil, loginTemplate, "", w, r)
return
success:
// get the destination
next := r.URL.Query().Get("next")
if next == "" || next[0] != '/' {
next = "/"
}
// and redirect to it!
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (auth *Auth) logoutRoute(w http.ResponseWriter, r *http.Request) {
// do the logout
auth.writeLogout(w, r)
// get the destination
next := r.URL.Query().Get("next")
if next == "" || next[0] != '/' {
next = "/"
}
// and redirect to it!
http.Redirect(w, r, next, http.StatusSeeOther)
}

View file

@ -16,8 +16,8 @@ import (
type Control struct { type Control struct {
component.Base component.Base
Dependencies struct { Dependencies struct {
Servables []component.Servable Routeables []component.Routeable
Cronables []component.Cronable Cronables []component.Cronable
} }
} }

View file

@ -22,12 +22,12 @@ type Home struct {
} }
var ( var (
_ component.Servable = (*Home)(nil) _ component.Routeable = (*Home)(nil)
) )
func (*Home) Routes() []string { return []string{"/"} } func (*Home) Routes() []string { return []string{"/"} }
func (home *Home) Handler(ctx context.Context, route string) (http.Handler, error) { func (home *Home) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
return home, nil return home, nil
} }

View file

@ -29,12 +29,12 @@ type Info struct {
var ( var (
_ component.DistilleryFetcher = (*Info)(nil) _ component.DistilleryFetcher = (*Info)(nil)
_ component.Servable = (*Info)(nil) _ component.Routeable = (*Info)(nil)
) )
func (*Info) Routes() []string { return []string{"/dis/"} } func (*Info) Routes() []string { return []string{"/dis/"} }
func (info *Info) Handler(ctx context.Context, route string) (handler http.Handler, err error) { func (info *Info) HandleRoute(ctx context.Context, route string) (handler http.Handler, err error) {
router := httprouter.New() router := httprouter.New()

View file

@ -5,6 +5,7 @@ import (
"io" "io"
"net/http" "net/http"
"github.com/FAU-CDI/wisski-distillery/pkg/cancel"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@ -12,20 +13,24 @@ import (
// The server may spawn background tasks, but these should be terminated once context closes. // The server may spawn background tasks, but these should be terminated once context closes.
// //
// Logging messages are directed to progress // Logging messages are directed to progress
func (control *Control) Server(ctx context.Context, progress io.Writer) (*http.ServeMux, error) { func (control *Control) Server(ctx context.Context, progress io.Writer) (http.Handler, error) {
// create a new mux // create a new mux
mux := http.NewServeMux() mux := http.NewServeMux()
// add all the servable routes! // add all the servable routes!
for _, s := range control.Dependencies.Servables { for _, s := range control.Dependencies.Routeables {
for _, route := range s.Routes() { for _, route := range s.Routes() {
zerolog.Ctx(ctx).Info().Str("component", s.Name()).Str("route", route).Msg("mounting route") zerolog.Ctx(ctx).Info().Str("component", s.Name()).Str("route", route).Msg("mounting route")
handler, err := s.Handler(ctx, route) handler, err := s.HandleRoute(ctx, route)
if err != nil { if err != nil {
return nil, err return nil, err
} }
mux.Handle(route, handler) mux.Handle(route, handler)
} }
} }
return mux, nil
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(cancel.ValuesOf(r.Context(), ctx))
mux.ServeHTTP(w, r)
}), nil
} }

View file

@ -21,7 +21,7 @@ type Assets struct {
Styles string // <link> tags inserted by the asset Styles string // <link> tags inserted by the asset
} }
//go:generate node build.mjs HomeHome ComponentsIndex ControlIndex ControlInstance InstanceComponentsIndex //go:generate node build.mjs HomeHome ComponentsIndex ControlIndex ControlInstance InstanceComponentsIndex AuthLogin
// MustParse parses a new template from the given source // MustParse parses a new template from the given source
// and calls [RegisterAssoc] on it. // and calls [RegisterAssoc] on it.

View file

@ -31,3 +31,9 @@ var AssetsInstanceComponentsIndex = Assets{
Scripts: `<script type="module" src="/static/HomeHome.38d394c2.js"></script><script src="/static/HomeHome.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/InstanceComponentsIndex.38d394c2.js"></script><script src="/static/InstanceComponentsIndex.38d394c2.js" nomodule="" defer></script>`, Scripts: `<script type="module" src="/static/HomeHome.38d394c2.js"></script><script src="/static/HomeHome.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/InstanceComponentsIndex.38d394c2.js"></script><script src="/static/InstanceComponentsIndex.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/HomeHome.a75f04fa.css"><link rel="stylesheet" href="/static/InstanceComponentsIndex.38d394c2.css">`, Styles: `<link rel="stylesheet" href="/static/HomeHome.a75f04fa.css"><link rel="stylesheet" href="/static/InstanceComponentsIndex.38d394c2.css">`,
} }
// AssetsAuthLogin contains assets for the 'AuthLogin' entrypoint.
var AssetsAuthLogin = Assets{
Scripts: `<script type="module" src="/static/HomeHome.38d394c2.js"></script><script src="/static/HomeHome.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/AuthLogin.38d394c2.js"></script><script src="/static/AuthLogin.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/HomeHome.a75f04fa.css"><link rel="stylesheet" href="/static/AuthLogin.38d394c2.css">`,
}

View file

@ -0,0 +1 @@
/* nothing for now */

View file

@ -0,0 +1 @@
// nothing for now

View file

@ -15,7 +15,7 @@ type Static struct {
} }
var ( var (
_ component.Servable = (*Static)(nil) _ component.Routeable = (*Static)(nil)
) )
func (*Static) Routes() []string { return []string{"/static/"} } func (*Static) Routes() []string { return []string{"/static/"} }
@ -23,7 +23,7 @@ func (*Static) Routes() []string { return []string{"/static/"} }
//go:embed dist //go:embed dist
var staticFS embed.FS var staticFS embed.FS
func (static *Static) Handler(ctx context.Context, route string) (http.Handler, error) { func (static *Static) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
// take the filesystem // take the filesystem
fs, err := fs.Sub(staticFS, "dist") fs, err := fs.Sub(staticFS, "dist")
if err != nil { if err != nil {

View file

@ -28,13 +28,15 @@ type Resolver struct {
} }
var ( var (
_ component.Servable = (*Resolver)(nil) _ component.Routeable = (*Resolver)(nil)
_ component.Cronable = (*Resolver)(nil) _ component.Cronable = (*Resolver)(nil)
) )
func (resolver *Resolver) Routes() []string { return []string{"/go/", "/wisski/get/"} } func (resolver *Resolver) Routes() []string { return []string{"/go/", "/wisski/get/"} }
func (resolver *Resolver) Handler(ctx context.Context, route string) (http.Handler, error) { func (resolver *Resolver) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
logger := zerolog.Ctx(ctx)
var err error var err error
return resolver.handler.Get(func() (p wdresolve.ResolveHandler) { return resolver.handler.Get(func() (p wdresolve.ResolveHandler) {
p.TrustXForwardedProto = true p.TrustXForwardedProto = true
@ -47,13 +49,13 @@ func (resolver *Resolver) Handler(ctx context.Context, route string) (http.Handl
domainName := resolver.Config.DefaultDomain domainName := resolver.Config.DefaultDomain
if domainName != "" { if domainName != "" {
fallback.Data[fmt.Sprintf("^https?://(.*)\\.%s", regexp.QuoteMeta(domainName))] = fmt.Sprintf("https://$1.%s", domainName) fallback.Data[fmt.Sprintf("^https?://(.*)\\.%s", regexp.QuoteMeta(domainName))] = fmt.Sprintf("https://$1.%s", domainName)
zerolog.Ctx(ctx).Info().Str("name", domainName).Msg("registering default domain") logger.Info().Str("name", domainName).Msg("registering default domain")
} }
// handle the extra domains! // handle the extra domains!
for _, domain := range resolver.Config.SelfExtraDomains { for _, domain := range resolver.Config.SelfExtraDomains {
fallback.Data[fmt.Sprintf("^https?://(.*)\\.%s", regexp.QuoteMeta(domain))] = fmt.Sprintf("https://$1.%s", domainName) fallback.Data[fmt.Sprintf("^https?://(.*)\\.%s", regexp.QuoteMeta(domain))] = fmt.Sprintf("https://$1.%s", domainName)
zerolog.Ctx(ctx).Info().Str("name", domainName).Msg("registering legacy domain") logger.Info().Str("name", domainName).Msg("registering legacy domain")
} }
// resolve the prefixes // resolve the prefixes

View file

@ -5,13 +5,13 @@ import (
"net/http" "net/http"
) )
// Servable is a component that is servable // Routeable is a component that is servable
type Servable interface { type Routeable interface {
Component Component
// Routes returns the routes served by this servable // Routes returns the routes served by this servable
Routes() []string Routes() []string
// Handler returns the handler for the requested route // HandleRoute returns the handler for the requested route
Handler(ctx context.Context, route string) (http.Handler, error) HandleRoute(ctx context.Context, route string) (http.Handler, error)
} }

View file

@ -103,6 +103,11 @@ func (sql *SQL) Update(ctx context.Context, progress io.Writer) error {
&models.Lock{}, &models.Lock{},
models.LockTable, models.LockTable,
}, },
{
"users",
&models.User{},
models.UserTable,
},
} }
// migrate all of the tables! // migrate all of the tables!

View file

@ -7,6 +7,7 @@ import (
"time" "time"
"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/dis/component/auth"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/control"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/cron" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/cron"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/home" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/home"
@ -71,6 +72,9 @@ func (dis *Distillery) SQL() *sql.SQL {
func (dis *Distillery) SSH() *ssh2.SSH2 { func (dis *Distillery) SSH() *ssh2.SSH2 {
return export[*ssh2.SSH2](dis) return export[*ssh2.SSH2](dis)
} }
func (dis *Distillery) Auth() *auth.Auth {
return export[*auth.Auth](dis)
}
func (dis *Distillery) Cron() *cron.Cron { func (dis *Distillery) Cron() *cron.Cron {
return export[*cron.Cron](dis) return export[*cron.Cron](dis)
@ -121,6 +125,9 @@ func (dis *Distillery) allComponents() []initFunc {
s.PollInterval = time.Second s.PollInterval = time.Second
}), }),
// auth
auto[*auth.Auth],
// instances // instances
auto[*instances.Instances], auto[*instances.Instances],
auto[*meta.Meta], auto[*meta.Meta],

15
internal/models/user.go Normal file
View file

@ -0,0 +1,15 @@
package models
// UserTable is the name of the table the [`User`] model is stored in.
const UserTable = "users"
// User represents a distillery user
type User struct {
Pk uint `gorm:"column:pk;primaryKey"`
User string `gorm:"column:user;not null;unique"` // name of the user
PasswordHash []byte `gorm:"column:password"` // password of the user, hashed
Enabled bool `gorm:"enabled;not null"`
Admin bool `gorm:"column:admin;not null"`
}

24
pkg/cancel/values.go Normal file
View file

@ -0,0 +1,24 @@
package cancel
import "context"
// ValuesOf returns a new context that has the same deadline and cancelation behviour as parent.
// However when requesting values from the context, checks the values in context first.
func ValuesOf(parent, values context.Context) context.Context {
return &valuesOf{
Context: parent,
values: values,
}
}
type valuesOf struct {
context.Context
values context.Context
}
func (vv *valuesOf) Value(key any) any {
if value := vv.values.Value(key); value != nil {
return value
}
return vv.Context.Value(key)
}

View file

@ -28,7 +28,7 @@ func (ei ErrInterceptor) Intercept(w http.ResponseWriter, r *http.Request, err e
res = ei.Fallback res = ei.Fallback
} }
res.ServerHTTP(w, r) res.ServeHTTP(w, r)
return true return true
} }
@ -65,13 +65,13 @@ var (
) )
var ( var (
textInterceptor = StatusInterceptor("text/plain", func(code int, text string) ([]byte, error) { TextInterceptor = StatusInterceptor("text/plain", func(code int, text string) ([]byte, error) {
return []byte(text), nil return []byte(text), nil
}) })
jsonInterceptor = StatusInterceptor("application/json", func(code int, text string) ([]byte, error) { JSONInterceptor = StatusInterceptor("application/json", func(code int, text string) ([]byte, error) {
return json.Marshal(map[string]any{"status": text, "code": code}) return json.Marshal(map[string]any{"status": text, "code": code})
}) })
htmlInterceptor = StatusInterceptor("text/html", func(code int, text string) ([]byte, error) { HTMLInterceptor = StatusInterceptor("text/html", func(code int, text string) ([]byte, error) {
return []byte(`<!DOCTYPE HTML><title>` + text + `</title>` + text), nil return []byte(`<!DOCTYPE HTML><title>` + text + `</title>` + text), nil
}) })
) )

View file

@ -5,6 +5,25 @@ import (
"net/http" "net/http"
) )
// WriteHTML writes a html response of type T to w.
// If an error occured, writes an error response instead.
func WriteHTML[T any](result T, err error, template *template.Template, templateName string, w http.ResponseWriter, r *http.Request) {
// intercept any errors
if HTMLInterceptor.Intercept(w, r, err) {
return
}
// write out the response as html
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
if templateName != "" {
template.ExecuteTemplate(w, templateName, result)
} else {
template.Execute(w, result)
}
}
type HTMLHandler[T any] struct { type HTMLHandler[T any] struct {
Handler func(r *http.Request) (T, error) Handler func(r *http.Request) (T, error)
@ -16,19 +35,5 @@ type HTMLHandler[T any] struct {
func (h HTMLHandler[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h HTMLHandler[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// call the function // call the function
result, err := h.Handler(r) result, err := h.Handler(r)
WriteHTML(result, err, h.Template, h.TemplateName, w, r)
// intercept any 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)
if h.TemplateName != "" {
h.Template.ExecuteTemplate(w, h.TemplateName, result)
} else {
h.Template.Execute(w, result)
}
} }

View file

@ -11,7 +11,7 @@ type Response struct {
StatusCode int // defaults to [http.StatusOK] StatusCode int // defaults to [http.StatusOK]
} }
func (response Response) ServerHTTP(w http.ResponseWriter, r *http.Request) { func (response Response) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if response.ContentType == "" { if response.ContentType == "" {
response.ContentType = "text/plain" response.ContentType = "text/plain"
} }

View file

@ -10,17 +10,11 @@ func JSON[T any](f func(r *http.Request) (T, error)) JSONHandler[T] {
return JSONHandler[T](f) return JSONHandler[T](f)
} }
// JSONHandler implements [http.Handler] by returning values as json to the caller. // WriteJSON writes a JSON response of type T to w.
// In case of an error, a generic "internal server error" message is returned. // If an error occured, writes an error response instead.
type JSONHandler[T any] func(r *http.Request) (T, error) func WriteJSON[T any](result T, err error, w http.ResponseWriter, r *http.Request) {
// ServeHTTP calls j(r) and returns json
func (j JSONHandler[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// call the function
result, err := j(r)
// handle any errors // handle any errors
if jsonInterceptor.Intercept(w, r, err) { if JSONInterceptor.Intercept(w, r, err) {
return return
} }
@ -29,3 +23,13 @@ func (j JSONHandler[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result) json.NewEncoder(w).Encode(result)
} }
// JSONHandler implements [http.Handler] by returning values as json to the caller.
// In case of an error, a generic "internal server error" message is returned.
type JSONHandler[T any] func(r *http.Request) (T, error)
// ServeHTTP calls j(r) and returns json
func (j JSONHandler[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) {
result, err := j(r)
WriteJSON(result, err, w, r)
}

View file

@ -13,7 +13,7 @@ func (rh RedirectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
url, code, err := rh(r) url, code, err := rh(r)
// intercept the errors // intercept the errors
if textInterceptor.Intercept(w, r, err) { if TextInterceptor.Intercept(w, r, err) {
return return
} }