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

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>