Add TOTP Token to account

This commit is contained in:
Tom Wiesing 2022-12-29 10:42:48 +01:00
parent b9795be745
commit da32b67981
No known key found for this signature in database
21 changed files with 724 additions and 13 deletions

File diff suppressed because one or more lines are too long

View file

@ -57,5 +57,23 @@ func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler,
router.Handler(http.MethodPost, route+"password", password)
}
{
totpenable := auth.authTOTPEnable(ctx)
router.Handler(http.MethodGet, route+"totp/enable", totpenable)
router.Handler(http.MethodPost, route+"totp/enable", totpenable)
}
{
totpenroll := auth.authTOTPEnroll(ctx)
router.Handler(http.MethodGet, route+"totp/enroll", totpenroll)
router.Handler(http.MethodPost, route+"totp/enroll", totpenroll)
}
{
totpdisable := auth.authTOTPDisable(ctx)
router.Handler(http.MethodGet, route+"totp/disable", totpdisable)
router.Handler(http.MethodPost, route+"totp/disable", totpdisable)
}
return router, nil
}

View file

@ -37,8 +37,9 @@ type authpasswordContext struct {
var (
errPasswordsNotIdentical = errors.New("passwords are not identical")
errPasswordIsEmpty = errors.New("password is empty")
errPasswordIncorrect = errors.New("old password is not correct")
errCredentialsIncorrect = errors.New("credentials are not correct")
errPasswordSetFailure = errors.New("error saving new password")
errTOTPSetFailure = errors.New("unable to disable totp")
errPasswordSet = errors.New("password was updated")
)
@ -46,6 +47,7 @@ func (auth *Auth) authPassword(ctx context.Context) http.Handler {
return &httpx.Form[struct{}]{
Fields: []httpx.Field{
{Name: "old", Type: httpx.PasswordField, EmptyOnError: true, Label: "Current Password"},
{Name: "passcode", Type: httpx.TextField, EmptyOnError: true, Label: "Current Passcode (optional)"},
{Name: "new", Type: httpx.PasswordField, EmptyOnError: true, Label: "New Password"},
{Name: "new2", Type: httpx.PasswordField, EmptyOnError: true, Label: "New Password (again)"},
},
@ -65,7 +67,7 @@ func (auth *Auth) authPassword(ctx context.Context) http.Handler {
},
Validate: func(r *http.Request, values map[string]string) (struct{}, error) {
old, new, new2 := values["old"], values["new"], values["new2"]
old, passcode, new, new2 := values["old"], values["passcode"], values["new"], values["new2"]
if new != new2 {
return struct{}{}, errPasswordsNotIdentical
@ -81,9 +83,9 @@ func (auth *Auth) authPassword(ctx context.Context) http.Handler {
}
{
err := user.CheckPassword(r.Context(), []byte(old))
err := user.CheckCredentials(r.Context(), []byte(old), passcode)
if err != nil {
return struct{}{}, errPasswordIncorrect
return struct{}{}, errCredentialsIncorrect
}
}
{

View file

@ -174,6 +174,7 @@ func (auth *Auth) authLogin(ctx context.Context) http.Handler {
Fields: []httpx.Field{
{Name: "username", Type: httpx.TextField, Label: "Username"},
{Name: "password", Type: httpx.PasswordField, EmptyOnError: true, Label: "Password"},
{Name: "passcode", Type: httpx.TextField, EmptyOnError: true, Label: "Passcode (optional)"},
},
FieldTemplate: httpx.PureCSSFieldTemplate,
@ -191,7 +192,7 @@ func (auth *Auth) authLogin(ctx context.Context) http.Handler {
},
Validate: func(r *http.Request, values map[string]string) (*AuthUser, error) {
username, password := values["username"], values["password"]
username, password, passcode := values["username"], values["password"], values["passcode"]
// make sure that the user exists
user, err := auth.User(ctx, username)
@ -199,8 +200,8 @@ func (auth *Auth) authLogin(ctx context.Context) http.Handler {
return nil, err
}
// check the password (TODO: Support TOTP)
err = user.CheckPassword(ctx, []byte(password))
// check the password and totp
err = user.CheckCredentials(ctx, []byte(password), passcode)
if err != nil {
return nil, err
}

View file

@ -26,6 +26,11 @@
<div class="pure-u-1">
<div class="pure-button-group" role="group" role="Actions">
<a class="pure-button" href="/auth/password/">Change Password</a>
{{ if .User.TOTPEnabled }}
<a class="pure-button" href="/auth/totp/disable/">Disable TOTP</a>
{{ else }}
<a class="pure-button" href="/auth/totp/enable/">Enable TOTP</a>
{{ end }}
</div>
</div>

View file

@ -0,0 +1,17 @@
{{ template "_form.html" . }}
{{ define "form/title" }}Disable TOTP{{ end }}
{{ define "form/button" }}Disable{{ end }}
{{ define "form/extra" }}
<div>
<a class="pure-button" href="/auth/">Back</a>
<hr />
</div>
{{ end }}
{{ define "form/inside" }}
<div>
<ul>
<li>remove the TOTP token from your account</li>
<li>your account will be less secure, but you will be able to login without it</li>
</ul>
</div>
{{ end }}

View file

@ -0,0 +1,18 @@
{{ template "_form.html" . }}
{{ define "form/title" }}Enable TOTP{{ end }}
{{ define "form/button" }}Enable{{ end }}
{{ define "form/extra" }}
<div>
<a class="pure-button" href="/auth/">Back</a>
<hr />
</div>
{{ end }}
{{ define "form/inside" }}
<div>
<ul>
<li>Use this page to add a <a href="https://en.wikipedia.org/wiki/Time-based_one-time_password">TOTP</a> token to your account</li>
<li>You will not be able to login without the second factor</li>
<li>If you forget your token, only an administrator can reset it</li>
</ul>
</div>
{{ end }}

View file

@ -0,0 +1,20 @@
{{ template "_form.html" . }}
{{ define "form/title" }}Enable TOTP{{ end }}
{{ define "form/button" }}Enable{{ end }}
{{ define "form/extra" }}
<div>
<a class="pure-button" href="/auth/">Back</a>
<hr />
</div>
{{ end }}
{{ define "form/inside" }}
<div>
<a href="{{ .TOTPURL }}">
<img src="{{ .TOTPImage }}" alt="TOTP Enrollment Image">
</a>
<ul>
<li>scan the token above using a <a href="https://en.wikipedia.org/wiki/Time-based_one-time_password">TOTP</a>app on your phone</li>
<li>enter your current password and the now generated token to confirm</li>
</ul>
</div>
{{ end }}

View file

@ -0,0 +1,214 @@
package auth
import (
"context"
"html/template"
"net/http"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
_ "embed"
)
type totpContext struct {
Message string
Form template.HTML
}
//go:embed "templates/totp_enable.html"
var totpEnableStr string
var totpEnableTemplate = static.AssetsAuthLogin.MustParseShared("totp_enable.html", totpEnableStr)
func (auth *Auth) authTOTPEnable(ctx context.Context) http.Handler {
return &httpx.Form[struct{}]{
Fields: []httpx.Field{
{Name: "password", Type: httpx.PasswordField, EmptyOnError: true, Label: "Current Password"},
},
FieldTemplate: httpx.PureCSSFieldTemplate,
CSRF: auth.csrf.Get(nil),
SkipForm: func(r *http.Request) (data struct{}, skip bool) {
user, _ := auth.UserOf(r)
return struct{}{}, user != nil && user.TOTPEnabled
},
RenderForm: func(template template.HTML, err error, w http.ResponseWriter, r *http.Request) {
ctx := totpContext{
Message: "",
Form: template,
}
if err != nil {
ctx.Message = err.Error()
}
httpx.WriteHTML(ctx, nil, totpEnableTemplate, "", w, r)
},
Validate: func(r *http.Request, values map[string]string) (struct{}, error) {
password := values["password"]
user, err := auth.UserOf(r)
if err != nil {
return struct{}{}, err
}
{
err := user.CheckPassword(r.Context(), []byte(password))
if err != nil {
return struct{}{}, errCredentialsIncorrect
}
}
{
_, err := user.NewTOTP(r.Context())
if err != nil {
return struct{}{}, errTOTPSetFailure
}
}
return struct{}{}, nil
},
RenderSuccess: func(_ struct{}, values map[string]string, w http.ResponseWriter, r *http.Request) error {
http.Redirect(w, r, "/auth/totp/enroll", http.StatusSeeOther)
return nil
},
}
}
//go:embed "templates/totp_enroll.html"
var totpEnrollStr string
var totpEnrollTemplate = static.AssetsAuthLogin.MustParseShared("totp_enroll.html", totpEnrollStr)
type totpEnrollContext struct {
totpContext
TOTPImage template.URL
TOTPURL template.URL
}
func (auth *Auth) authTOTPEnroll(ctx context.Context) http.Handler {
return &httpx.Form[struct{}]{
Fields: []httpx.Field{
{Name: "password", Type: httpx.PasswordField, EmptyOnError: true, Label: "Current Password"},
{Name: "passcode", Type: httpx.TextField, EmptyOnError: true, Label: "Passcode"},
},
FieldTemplate: httpx.PureCSSFieldTemplate,
CSRF: auth.csrf.Get(nil),
SkipForm: func(r *http.Request) (data struct{}, skip bool) {
user, _ := auth.UserOf(r)
return struct{}{}, user != nil && user.TOTPEnabled
},
RenderForm: func(tpl template.HTML, err error, w http.ResponseWriter, r *http.Request) {
ctx := totpEnrollContext{
totpContext: totpContext{
Message: "",
Form: tpl,
},
}
if user, err := auth.UserOf(r); err == nil && user != nil {
secret, err := user.TOTP()
if err == nil {
img, _ := TOTPLink(secret, 500, 500)
ctx.TOTPImage = template.URL(img)
ctx.TOTPURL = template.URL(secret.URL())
}
}
if err != nil {
ctx.Message = err.Error()
}
httpx.WriteHTML(ctx, nil, totpEnrollTemplate, "", w, r)
},
Validate: func(r *http.Request, values map[string]string) (struct{}, error) {
password, passcode := values["password"], values["passcode"]
user, err := auth.UserOf(r)
if err != nil {
return struct{}{}, err
}
{
err := user.CheckPassword(r.Context(), []byte(password))
if err != nil {
return struct{}{}, errCredentialsIncorrect
}
}
{
err := user.EnableTOTP(r.Context(), passcode)
if err != nil {
return struct{}{}, errTOTPSetFailure
}
}
return struct{}{}, nil
},
RenderSuccess: func(_ struct{}, values map[string]string, w http.ResponseWriter, r *http.Request) error {
http.Redirect(w, r, "/auth/", http.StatusSeeOther)
return nil
},
}
}
//go:embed "templates/totp_disable.html"
var totpDisableStr string
var totpDisableTemplate = static.AssetsAuthLogin.MustParseShared("totp_disable.html", totpDisableStr)
func (auth *Auth) authTOTPDisable(ctx context.Context) http.Handler {
return &httpx.Form[struct{}]{
Fields: []httpx.Field{
{Name: "password", Type: httpx.PasswordField, EmptyOnError: true, Label: "Current Password"},
{Name: "passcode", Type: httpx.TextField, EmptyOnError: true, Label: "Current Passcode"},
},
FieldTemplate: httpx.PureCSSFieldTemplate,
CSRF: auth.csrf.Get(nil),
SkipForm: func(r *http.Request) (data struct{}, skip bool) {
user, _ := auth.UserOf(r)
return struct{}{}, user != nil && !user.TOTPEnabled
},
RenderForm: func(template template.HTML, err error, w http.ResponseWriter, r *http.Request) {
ctx := totpContext{
Message: "",
Form: template,
}
if err != nil {
ctx.Message = err.Error()
}
httpx.WriteHTML(ctx, nil, totpDisableTemplate, "", w, r)
},
Validate: func(r *http.Request, values map[string]string) (struct{}, error) {
password, passcode := values["password"], values["passcode"]
user, err := auth.UserOf(r)
if err != nil {
return struct{}{}, err
}
{
err := user.CheckCredentials(r.Context(), []byte(password), passcode)
if err != nil {
return struct{}{}, errCredentialsIncorrect
}
}
{
err := user.DisableTOTP(r.Context())
if err != nil {
return struct{}{}, errTOTPSetFailure
}
}
return struct{}{}, nil
},
RenderSuccess: func(_ struct{}, values map[string]string, w http.ResponseWriter, r *http.Request) error {
http.Redirect(w, r, "/auth/", http.StatusSeeOther)
return nil
},
}
}

View file

@ -1,11 +1,16 @@
package auth
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"image/png"
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)
@ -112,6 +117,90 @@ func (au *AuthUser) String() string {
return fmt.Sprintf("User{Name:%q,Enabled:%t,HasPassword:%t,Admin:%t}", au.User.User, au.User.Enabled, hasPassword, au.User.Admin)
}
var (
ErrTOTPEnabled = errors.New("TOTP is enabled")
ErrTOTPDisabled = errors.New("TOTP is disabled")
ErrTOTPFailed = errors.New("TOTP failed")
)
func (au *AuthUser) TOTP() (*otp.Key, error) {
if au.TOTPURL == "" {
return nil, ErrTOTPDisabled
}
return otp.NewKeyFromURL(au.TOTPURL)
}
// CheckTOTP validates the given totp passcode against the saved secret.
// If totp is not enabled, any passcode will pass the check.
func (au *AuthUser) CheckTOTP(passcode string) error {
secret, err := au.TOTP()
if err != nil {
return err
}
if au.TOTPEnabled && !totp.Validate(passcode, secret.Secret()) {
return ErrTOTPFailed
}
return nil
}
// NewTOTP generates a new TOTP secret, returning a totp key.
func (au *AuthUser) NewTOTP(ctx context.Context) (*otp.Key, error) {
if au.User.TOTPEnabled {
return nil, ErrTOTPEnabled
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "WissKI Distillery",
AccountName: au.User.User,
})
if err != nil {
return nil, err
}
au.User.TOTPURL = key.URL()
return key, au.Save(ctx)
}
func TOTPLink(secret *otp.Key, width, height int) (string, error) {
// make an image
img, err := secret.Image(width, height)
if err != nil {
return "", err
}
// encode image as base64
var buffer bytes.Buffer
if err := png.Encode(&buffer, img); err != nil {
return "", err
}
// return the image url
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes()), nil
}
// EnableTOTP enables totp for the given user
func (au *AuthUser) EnableTOTP(ctx context.Context, passcode string) error {
secret, err := au.TOTP()
if err != nil {
return err
}
if !totp.Validate(passcode, secret.Secret()) {
return ErrTOTPFailed
}
au.User.TOTPEnabled = true
return au.Save(ctx)
}
// DisableTOTP disables totp for the given user
func (au *AuthUser) DisableTOTP(ctx context.Context) (err error) {
au.User.TOTPEnabled = false
au.User.TOTPURL = ""
return au.Save(ctx)
}
// 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)
@ -150,6 +239,16 @@ func (au *AuthUser) CheckPassword(ctx context.Context, password []byte) error {
return bcrypt.CompareHashAndPassword(au.User.PasswordHash, password)
}
func (au *AuthUser) CheckCredentials(ctx context.Context, password []byte, passcode string) error {
if err := au.CheckPassword(ctx, password); err != nil {
return err
}
if err := au.CheckTOTP(passcode); err != nil && err != ErrTOTPDisabled {
return err
}
return nil
}
// 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 {

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 AuthLogin AuthHome
//go:generate node build.mjs HomeHome ComponentsIndex ControlIndex ControlInstance InstanceComponentsIndex AuthLogin AuthHome AuthTOTP
// MustParse parses a new template from the given source
// and calls [RegisterAssoc] on it.

View file

@ -43,3 +43,9 @@ var AssetsAuthHome = 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/AuthHome.38d394c2.js"></script><script src="/static/AuthHome.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/HomeHome.a75f04fa.css"><link rel="stylesheet" href="/static/AuthHome.38d394c2.css">`,
}
// AssetsAuthTOTP contains assets for the 'AuthTOTP' entrypoint.
var AssetsAuthTOTP = 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/AuthTOTP.38d394c2.js"></script><script src="/static/AuthTOTP.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/HomeHome.a75f04fa.css"><link rel="stylesheet" href="/static/AuthTOTP.38d394c2.css">`,
}

View file

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

View file

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

View file

@ -23,6 +23,7 @@
<form class="pure-form pure-form-aligned" method="POST">
<fieldset>
<legend>{{ template "form/title" . }}</legend>
{{ block "form/inside" . }}<!-- no inside -->{{ end }}
{{ .Form }}
<input type="submit" value="{{ block "form/button" .}}Submit{{ end }}" class="pure-button">
</fieldset>

View file

@ -10,6 +10,9 @@ type User struct {
User string `gorm:"column:user;not null;unique"` // name of the user
PasswordHash []byte `gorm:"column:password"` // password of the user, hashed
TOTPEnabled bool `gorm:"column:totpenabled"` // is totp enabled for the user
TOTPURL string `gorm:"column:totp"` // the totp of the user
Enabled bool `gorm:"enabled;not null"`
Admin bool `gorm:"column:admin;not null"`
}