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

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"`
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
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_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
DIS_ADMIN_USER=${DIS_ADMIN_USER}
DIS_ADMIN_PASSWORD=${DIS_ADMIN_PASSWORD}
# 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"`
DisAdminPassword string `env:"DIS_ADMIN_PASSWORD"`
DockerNetworkName string `env:"DOCKER_NETWORK_NAME"`
SessionSecret string `env:"SESSION_SECRET"`
}
// SetDefaults sets defaults on the template
@ -94,6 +95,13 @@ func (tpl *Template) SetDefaults(env environment.Environment) (err error) {
tpl.DockerNetworkName = `distillery-` + tpl.DockerNetworkName
}
if tpl.SessionSecret == "" {
tpl.SessionSecret, err = password.Password(100)
if err != nil {
return err
}
}
return nil
}

View file

@ -18,7 +18,7 @@ func (dis *Distillery) init() {
lazy.RegisterPoolGroup[component.DistilleryFetcher](&dis.pool)
lazy.RegisterPoolGroup[component.Installable](&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)
})
}

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 {
component.Base
Dependencies struct {
Servables []component.Servable
Cronables []component.Cronable
Routeables []component.Routeable
Cronables []component.Cronable
}
}

View file

@ -22,12 +22,12 @@ type Home struct {
}
var (
_ component.Servable = (*Home)(nil)
_ component.Routeable = (*Home)(nil)
)
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
}

View file

@ -29,12 +29,12 @@ type Info struct {
var (
_ component.DistilleryFetcher = (*Info)(nil)
_ component.Servable = (*Info)(nil)
_ component.Routeable = (*Info)(nil)
)
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()

View file

@ -5,6 +5,7 @@ import (
"io"
"net/http"
"github.com/FAU-CDI/wisski-distillery/pkg/cancel"
"github.com/rs/zerolog"
)
@ -12,20 +13,24 @@ import (
// The server may spawn background tasks, but these should be terminated once context closes.
//
// 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
mux := http.NewServeMux()
// add all the servable routes!
for _, s := range control.Dependencies.Servables {
for _, s := range control.Dependencies.Routeables {
for _, route := range s.Routes() {
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 {
return nil, err
}
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
}
//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
// 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>`,
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 (
_ component.Servable = (*Static)(nil)
_ component.Routeable = (*Static)(nil)
)
func (*Static) Routes() []string { return []string{"/static/"} }
@ -23,7 +23,7 @@ func (*Static) Routes() []string { return []string{"/static/"} }
//go:embed dist
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
fs, err := fs.Sub(staticFS, "dist")
if err != nil {

View file

@ -28,13 +28,15 @@ type Resolver struct {
}
var (
_ component.Servable = (*Resolver)(nil)
_ component.Cronable = (*Resolver)(nil)
_ component.Routeable = (*Resolver)(nil)
_ component.Cronable = (*Resolver)(nil)
)
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
return resolver.handler.Get(func() (p wdresolve.ResolveHandler) {
p.TrustXForwardedProto = true
@ -47,13 +49,13 @@ func (resolver *Resolver) Handler(ctx context.Context, route string) (http.Handl
domainName := resolver.Config.DefaultDomain
if 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!
for _, domain := range resolver.Config.SelfExtraDomains {
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

View file

@ -5,13 +5,13 @@ import (
"net/http"
)
// Servable is a component that is servable
type Servable interface {
// Routeable is a component that is servable
type Routeable interface {
Component
// Routes returns the routes served by this servable
Routes() []string
// Handler returns the handler for the requested route
Handler(ctx context.Context, route string) (http.Handler, error)
// HandleRoute returns the handler for the requested route
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.LockTable,
},
{
"users",
&models.User{},
models.UserTable,
},
}
// migrate all of the tables!

View file

@ -7,6 +7,7 @@ import (
"time"
"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/cron"
"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 {
return export[*ssh2.SSH2](dis)
}
func (dis *Distillery) Auth() *auth.Auth {
return export[*auth.Auth](dis)
}
func (dis *Distillery) Cron() *cron.Cron {
return export[*cron.Cron](dis)
@ -121,6 +125,9 @@ func (dis *Distillery) allComponents() []initFunc {
s.PollInterval = time.Second
}),
// auth
auto[*auth.Auth],
// instances
auto[*instances.Instances],
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"`
}