Add TOTP Token to account
This commit is contained in:
parent
b9795be745
commit
da32b67981
21 changed files with 724 additions and 13 deletions
|
|
@ -3,6 +3,7 @@ package cmd
|
||||||
import (
|
import (
|
||||||
wisski_distillery "github.com/FAU-CDI/wisski-distillery"
|
wisski_distillery "github.com/FAU-CDI/wisski-distillery"
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/cli"
|
"github.com/FAU-CDI/wisski-distillery/internal/cli"
|
||||||
|
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth"
|
||||||
"github.com/tkw1536/goprogram/exit"
|
"github.com/tkw1536/goprogram/exit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -18,7 +19,10 @@ type disUser struct {
|
||||||
|
|
||||||
SetPassword bool `short:"s" long:"set-password" description:"interactively set a user password"`
|
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"`
|
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"`
|
CheckPassword bool `short:"p" long:"check-password" description:"interactively check a user credential"`
|
||||||
|
|
||||||
|
EnableTOTP bool `short:"t" long:"enable-totp" description:"interactively enroll a user in totp"`
|
||||||
|
DisableTOTP bool `short:"v" long:"disable-totp" description:"disable totp for a user"`
|
||||||
|
|
||||||
Positionals struct {
|
Positionals struct {
|
||||||
User string `positional-arg-name:"USER" description:"username to manage. May be omitted for some actions"`
|
User string `positional-arg-name:"USER" description:"username to manage. May be omitted for some actions"`
|
||||||
|
|
@ -50,6 +54,8 @@ func (du disUser) AfterParse() error {
|
||||||
du.UnsetPassword,
|
du.UnsetPassword,
|
||||||
du.CheckPassword,
|
du.CheckPassword,
|
||||||
du.ListUsers,
|
du.ListUsers,
|
||||||
|
du.DisableTOTP,
|
||||||
|
du.EnableTOTP,
|
||||||
} {
|
} {
|
||||||
if action {
|
if action {
|
||||||
counter++
|
counter++
|
||||||
|
|
@ -83,6 +89,10 @@ func (du disUser) Run(context wisski_distillery.Context) error {
|
||||||
return du.runCheckPassword(context)
|
return du.runCheckPassword(context)
|
||||||
case du.ListUsers:
|
case du.ListUsers:
|
||||||
return du.runListUsers(context)
|
return du.runListUsers(context)
|
||||||
|
case du.EnableTOTP:
|
||||||
|
return du.runEnableTOTP(context)
|
||||||
|
case du.DisableTOTP:
|
||||||
|
return du.runDisableTOTP(context)
|
||||||
}
|
}
|
||||||
panic("never reached")
|
panic("never reached")
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +182,18 @@ func (du disUser) runCheckPassword(context wisski_distillery.Context) error {
|
||||||
}
|
}
|
||||||
context.Println()
|
context.Println()
|
||||||
|
|
||||||
return user.CheckPassword(context.Context, []byte(candidate))
|
var passcode string
|
||||||
|
if user.TOTPEnabled {
|
||||||
|
context.Printf("Enter passcode for %s:", du.Positionals.User)
|
||||||
|
|
||||||
|
passcode, err = context.IOStream.ReadPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
context.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.CheckCredentials(context.Context, []byte(candidate), passcode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (du disUser) runListUsers(context wisski_distillery.Context) error {
|
func (du disUser) runListUsers(context wisski_distillery.Context) error {
|
||||||
|
|
@ -185,3 +206,43 @@ func (du disUser) runListUsers(context wisski_distillery.Context) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (du disUser) runEnableTOTP(context wisski_distillery.Context) error {
|
||||||
|
user, err := context.Environment.Auth().User(context.Context, du.Positionals.User)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the secret
|
||||||
|
key, err := user.NewTOTP(context.Context)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// print out the link
|
||||||
|
url, err := auth.TOTPLink(key, 100, 100)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
context.Println(url)
|
||||||
|
|
||||||
|
// request the passcode
|
||||||
|
context.Printf("Enter passcode for %s:", du.Positionals.User)
|
||||||
|
passcode, err := context.IOStream.ReadPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
context.Println()
|
||||||
|
|
||||||
|
// and enter it
|
||||||
|
return user.EnableTOTP(context.Context, passcode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (du disUser) runDisableTOTP(context wisski_distillery.Context) error {
|
||||||
|
user, err := context.Environment.Auth().User(context.Context, du.Positionals.User)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.DisableTOTP(context.Context)
|
||||||
|
}
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -27,6 +27,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/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/gorilla/securecookie v1.1.1 // 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
|
||||||
|
|
@ -34,6 +35,7 @@ require (
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||||
|
github.com/pquerna/otp v1.4.0 // indirect
|
||||||
golang.org/x/sys v0.3.0 // indirect
|
golang.org/x/sys v0.3.0 // indirect
|
||||||
golang.org/x/tools v0.4.0 // indirect
|
golang.org/x/tools v0.4.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
8
go.sum
8
go.sum
|
|
@ -6,7 +6,10 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVK
|
||||||
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/feiin/sqlstring v0.3.0 h1:iyPEFijI2BxpY2M+AuhIvdNManzXa2OwGzuPaEMLUgo=
|
github.com/feiin/sqlstring v0.3.0 h1:iyPEFijI2BxpY2M+AuhIvdNManzXa2OwGzuPaEMLUgo=
|
||||||
github.com/feiin/sqlstring v0.3.0/go.mod h1:xpZTjVUw1nD3hMgF9SMRdPiooKSikLf4PS5j2NTn3RI=
|
github.com/feiin/sqlstring v0.3.0/go.mod h1:xpZTjVUw1nD3hMgF9SMRdPiooKSikLf4PS5j2NTn3RI=
|
||||||
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
|
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
|
||||||
|
|
@ -43,9 +46,14 @@ github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peK
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||||
|
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
|
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
|
||||||
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/tkw1536/goprogram v0.2.4 h1:1l3+j8xjY3E3uf+ba3QRGWm09ucFCKrnNLq6g1Gq8YA=
|
github.com/tkw1536/goprogram v0.2.4 h1:1l3+j8xjY3E3uf+ba3QRGWm09ucFCKrnNLq6g1Gq8YA=
|
||||||
github.com/tkw1536/goprogram v0.2.4/go.mod h1:3Ngcwy7jtsZ+pINc+JfLdf8TWbvthdSS2T6Vbg44Fy8=
|
github.com/tkw1536/goprogram v0.2.4/go.mod h1:3Ngcwy7jtsZ+pINc+JfLdf8TWbvthdSS2T6Vbg44Fy8=
|
||||||
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -57,5 +57,23 @@ func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler,
|
||||||
router.Handler(http.MethodPost, route+"password", password)
|
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
|
return router, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,9 @@ type authpasswordContext struct {
|
||||||
var (
|
var (
|
||||||
errPasswordsNotIdentical = errors.New("passwords are not identical")
|
errPasswordsNotIdentical = errors.New("passwords are not identical")
|
||||||
errPasswordIsEmpty = errors.New("password is empty")
|
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")
|
errPasswordSetFailure = errors.New("error saving new password")
|
||||||
|
errTOTPSetFailure = errors.New("unable to disable totp")
|
||||||
errPasswordSet = errors.New("password was updated")
|
errPasswordSet = errors.New("password was updated")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ func (auth *Auth) authPassword(ctx context.Context) http.Handler {
|
||||||
return &httpx.Form[struct{}]{
|
return &httpx.Form[struct{}]{
|
||||||
Fields: []httpx.Field{
|
Fields: []httpx.Field{
|
||||||
{Name: "old", Type: httpx.PasswordField, EmptyOnError: true, Label: "Current Password"},
|
{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: "new", Type: httpx.PasswordField, EmptyOnError: true, Label: "New Password"},
|
||||||
{Name: "new2", Type: httpx.PasswordField, EmptyOnError: true, Label: "New Password (again)"},
|
{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) {
|
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 {
|
if new != new2 {
|
||||||
return struct{}{}, errPasswordsNotIdentical
|
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 {
|
if err != nil {
|
||||||
return struct{}{}, errPasswordIncorrect
|
return struct{}{}, errCredentialsIncorrect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|
@ -174,6 +174,7 @@ func (auth *Auth) authLogin(ctx context.Context) http.Handler {
|
||||||
Fields: []httpx.Field{
|
Fields: []httpx.Field{
|
||||||
{Name: "username", Type: httpx.TextField, Label: "Username"},
|
{Name: "username", Type: httpx.TextField, Label: "Username"},
|
||||||
{Name: "password", Type: httpx.PasswordField, EmptyOnError: true, Label: "Password"},
|
{Name: "password", Type: httpx.PasswordField, EmptyOnError: true, Label: "Password"},
|
||||||
|
{Name: "passcode", Type: httpx.TextField, EmptyOnError: true, Label: "Passcode (optional)"},
|
||||||
},
|
},
|
||||||
FieldTemplate: httpx.PureCSSFieldTemplate,
|
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) {
|
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
|
// make sure that the user exists
|
||||||
user, err := auth.User(ctx, username)
|
user, err := auth.User(ctx, username)
|
||||||
|
|
@ -199,8 +200,8 @@ func (auth *Auth) authLogin(ctx context.Context) http.Handler {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// check the password (TODO: Support TOTP)
|
// check the password and totp
|
||||||
err = user.CheckPassword(ctx, []byte(password))
|
err = user.CheckCredentials(ctx, []byte(password), passcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@
|
||||||
<div class="pure-u-1">
|
<div class="pure-u-1">
|
||||||
<div class="pure-button-group" role="group" role="Actions">
|
<div class="pure-button-group" role="group" role="Actions">
|
||||||
<a class="pure-button" href="/auth/password/">Change Password</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
17
internal/dis/component/auth/templates/totp_disable.html
Normal file
17
internal/dis/component/auth/templates/totp_disable.html
Normal 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 }}
|
||||||
18
internal/dis/component/auth/templates/totp_enable.html
Normal file
18
internal/dis/component/auth/templates/totp_enable.html
Normal 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 }}
|
||||||
20
internal/dis/component/auth/templates/totp_enroll.html
Normal file
20
internal/dis/component/auth/templates/totp_enroll.html
Normal 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 }}
|
||||||
214
internal/dis/component/auth/totp.go
Normal file
214
internal/dis/component/auth/totp.go
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"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)
|
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
|
// SetPassword sets the password for this user and turns the user on
|
||||||
func (au *AuthUser) SetPassword(ctx context.Context, password []byte) (err error) {
|
func (au *AuthUser) SetPassword(ctx context.Context, password []byte) (err error) {
|
||||||
au.User.PasswordHash, err = bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
|
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)
|
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.
|
// 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.
|
// If the user is already an admin, does not return an error.
|
||||||
func (au *AuthUser) MakeAdmin(ctx context.Context) error {
|
func (au *AuthUser) MakeAdmin(ctx context.Context) error {
|
||||||
|
|
|
||||||
|
|
@ -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 AuthLogin AuthHome
|
//go:generate node build.mjs HomeHome ComponentsIndex ControlIndex ControlInstance InstanceComponentsIndex AuthLogin AuthHome AuthTOTP
|
||||||
|
|
||||||
// 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.
|
||||||
|
|
|
||||||
|
|
@ -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>`,
|
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">`,
|
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">`,
|
||||||
|
}
|
||||||
|
|
|
||||||
0
internal/dis/component/control/static/dist/AuthTOTP.38d394c2.css
vendored
Normal file
0
internal/dis/component/control/static/dist/AuthTOTP.38d394c2.css
vendored
Normal file
0
internal/dis/component/control/static/dist/AuthTOTP.38d394c2.js
vendored
Normal file
0
internal/dis/component/control/static/dist/AuthTOTP.38d394c2.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/* nothing for now */
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
// nothing for now
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
<form class="pure-form pure-form-aligned" method="POST">
|
<form class="pure-form pure-form-aligned" method="POST">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{{ template "form/title" . }}</legend>
|
<legend>{{ template "form/title" . }}</legend>
|
||||||
|
{{ block "form/inside" . }}<!-- no inside -->{{ end }}
|
||||||
{{ .Form }}
|
{{ .Form }}
|
||||||
<input type="submit" value="{{ block "form/button" .}}Submit{{ end }}" class="pure-button">
|
<input type="submit" value="{{ block "form/button" .}}Submit{{ end }}" class="pure-button">
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ type User struct {
|
||||||
User string `gorm:"column:user;not null;unique"` // name of the user
|
User string `gorm:"column:user;not null;unique"` // name of the user
|
||||||
PasswordHash []byte `gorm:"column:password"` // password of the user, hashed
|
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"`
|
Enabled bool `gorm:"enabled;not null"`
|
||||||
Admin bool `gorm:"column:admin;not null"`
|
Admin bool `gorm:"column:admin;not null"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue