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

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!