Add password recovery form
This commit is contained in:
parent
482ed535c1
commit
b2d7fddb39
121
api/users.go
121
api/users.go
|
@ -36,7 +36,6 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
|
@ -53,18 +52,19 @@ import (
|
|||
|
||||
func init() {
|
||||
router.POST("/api/users", apiHandler(registerUser))
|
||||
router.PATCH("/api/users", apiHandler(resendValidationLink))
|
||||
router.PATCH("/api/users", apiHandler(specialUserOperations))
|
||||
router.GET("/api/users/:uid", apiAuthHandler(sameUserHandler(getUser)))
|
||||
router.POST("/api/users/:uid/email", apiHandler(userHandler(validateUserAddress)))
|
||||
router.POST("/api/users/:uid/recovery", apiHandler(userHandler(recoverUserAccount)))
|
||||
}
|
||||
|
||||
type UploadedUser struct {
|
||||
Kind string
|
||||
Email string
|
||||
Password string
|
||||
}
|
||||
|
||||
func sendValidationLink(opts *config.Options, user *happydns.User) error {
|
||||
var toName string
|
||||
func genUsername(user *happydns.User) (toName string) {
|
||||
if n := strings.Index(user.Email, "+"); n > 0 {
|
||||
toName = user.Email[0:n]
|
||||
} else {
|
||||
|
@ -87,8 +87,11 @@ func sendValidationLink(opts *config.Options, user *happydns.User) error {
|
|||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("test to", user.Email, toName)
|
||||
func sendValidationLink(opts *config.Options, user *happydns.User) error {
|
||||
toName := genUsername(user)
|
||||
return utils.SendMail(
|
||||
&mail.Address{Name: toName, Address: user.Email},
|
||||
"Your new account on happyDNS",
|
||||
|
@ -106,6 +109,21 @@ In order to validate your account, please follow this link now:
|
|||
)
|
||||
}
|
||||
|
||||
func sendRecoveryLink(opts *config.Options, user *happydns.User) error {
|
||||
toName := genUsername(user)
|
||||
return utils.SendMail(
|
||||
&mail.Address{Name: toName, Address: user.Email},
|
||||
"Recover you happyDNS account",
|
||||
`Hi `+toName+`,
|
||||
|
||||
You've just ask on our platform to recover your account.
|
||||
|
||||
In order to define a new password, please follow this link now:
|
||||
|
||||
[Recover my account](`+opts.GetAccountRecoveryURL(user)+`)`,
|
||||
)
|
||||
}
|
||||
|
||||
func registerUser(opts *config.Options, p httprouter.Params, body io.Reader) Response {
|
||||
var uu UploadedUser
|
||||
err := json.NewDecoder(body).Decode(&uu)
|
||||
|
@ -152,7 +170,7 @@ func registerUser(opts *config.Options, p httprouter.Params, body io.Reader) Res
|
|||
}
|
||||
}
|
||||
|
||||
func resendValidationLink(opts *config.Options, p httprouter.Params, body io.Reader) Response {
|
||||
func specialUserOperations(opts *config.Options, p httprouter.Params, body io.Reader) Response {
|
||||
var uu UploadedUser
|
||||
err := json.NewDecoder(body).Decode(&uu)
|
||||
if err != nil {
|
||||
|
@ -161,27 +179,46 @@ func resendValidationLink(opts *config.Options, p httprouter.Params, body io.Rea
|
|||
}
|
||||
}
|
||||
|
||||
res := APIErrorResponse{
|
||||
err: errors.New("If this address exists in our database, you'll receive a new e-mail."),
|
||||
status: http.StatusOK,
|
||||
}
|
||||
|
||||
if user, err := storage.MainStore.GetUserByEmail(uu.Email); err != nil {
|
||||
log.Println(err)
|
||||
return APIErrorResponse{
|
||||
err: errors.New("If this address exists in our database, you'll receive a new validation link."),
|
||||
status: http.StatusOK,
|
||||
}
|
||||
} else if user.EmailValidated != nil {
|
||||
return APIErrorResponse{
|
||||
err: errors.New("If this address exists in our database, you'll receive a new validation link."),
|
||||
status: http.StatusOK,
|
||||
}
|
||||
} else if err = sendValidationLink(opts, user); err != nil {
|
||||
return APIErrorResponse{
|
||||
err: err,
|
||||
}
|
||||
return res
|
||||
} else {
|
||||
return APIErrorResponse{
|
||||
err: errors.New("If this address exists in our database, you'll receive a new validation link."),
|
||||
status: http.StatusOK,
|
||||
if uu.Kind == "recovery" {
|
||||
if user.EmailValidated == nil {
|
||||
if err = sendValidationLink(opts, user); err != nil {
|
||||
return APIErrorResponse{
|
||||
err: err,
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
if err = sendRecoveryLink(opts, user); err != nil {
|
||||
return APIErrorResponse{
|
||||
err: err,
|
||||
}
|
||||
|
||||
} else if err := storage.MainStore.UpdateUser(user); err != nil {
|
||||
return APIErrorResponse{
|
||||
err: fmt.Errorf("An error occurs when trying to recover your account: %w", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if uu.Kind == "validation" {
|
||||
if user.EmailValidated != nil {
|
||||
return res
|
||||
} else if err = sendValidationLink(opts, user); err != nil {
|
||||
return APIErrorResponse{
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func sameUserHandler(f func(*config.Options, *happydns.User, io.Reader) Response) func(*config.Options, *happydns.User, httprouter.Params, io.Reader) Response {
|
||||
|
@ -259,3 +296,41 @@ func validateUserAddress(opts *config.Options, user *happydns.User, body io.Read
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
type UploadedAccountRecovery struct {
|
||||
Key string
|
||||
Password string
|
||||
}
|
||||
|
||||
func recoverUserAccount(opts *config.Options, user *happydns.User, body io.Reader) Response {
|
||||
var uar UploadedAccountRecovery
|
||||
err := json.NewDecoder(body).Decode(&uar)
|
||||
if err != nil {
|
||||
return APIErrorResponse{
|
||||
err: fmt.Errorf("Something is wrong in received data: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
if err := user.CanRecoverAccount(uar.Key); err != nil {
|
||||
return APIErrorResponse{
|
||||
err: err,
|
||||
}
|
||||
} else if len(uar.Password) == 0 {
|
||||
return APIResponse{
|
||||
response: false,
|
||||
}
|
||||
} else if err := user.DefinePassword(uar.Password); err != nil {
|
||||
return APIErrorResponse{
|
||||
err: err,
|
||||
}
|
||||
} else if err := storage.MainStore.UpdateUser(user); err != nil {
|
||||
return APIErrorResponse{
|
||||
status: http.StatusNotFound,
|
||||
err: errors.New("User not found"),
|
||||
}
|
||||
} else {
|
||||
return APIResponse{
|
||||
response: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,10 +33,15 @@ package config
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"git.happydns.org/happydns/model"
|
||||
)
|
||||
|
||||
func (o *Options) GetRegistrationURL(u *happydns.User) string {
|
||||
return fmt.Sprintf("%s%s/email-validation?u=%x&k=%s", o.ExternalURL, o.BaseURL, u.Id, u.GenRegistrationHash(false))
|
||||
func (o *Options) GetAccountRecoveryURL(u *happydns.User) string {
|
||||
return fmt.Sprintf("%s%s/forgotten-password?u=%x&k=%s", o.ExternalURL, o.BaseURL, u.Id, url.QueryEscape(u.GenAccountRecoveryHash(false)))
|
||||
}
|
||||
|
||||
func (o *Options) GetRegistrationURL(u *happydns.User) string {
|
||||
return fmt.Sprintf("%s%s/email-validation?u=%x&k=%s", o.ExternalURL, o.BaseURL, u.Id, url.QueryEscape(u.GenRegistrationHash(false)))
|
||||
}
|
||||
|
|
|
@ -77,6 +77,13 @@ const routes = [
|
|||
return import(/* webpackChunkName: "signup" */ '../views/email-validation.vue')
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/forgotten-password',
|
||||
name: 'forgotten-password',
|
||||
component: function () {
|
||||
return import(/* webpackChunkName: "forgotten-password" */ '../views/forgotten-password.vue')
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/domains',
|
||||
name: 'domains',
|
||||
|
|
|
@ -128,6 +128,7 @@ export default {
|
|||
if (valid) {
|
||||
axios
|
||||
.patch('/api/users', {
|
||||
kind: 'validation',
|
||||
email: this.email
|
||||
})
|
||||
.then(
|
||||
|
|
|
@ -0,0 +1,242 @@
|
|||
<!--
|
||||
Copyright or © or Copr. happyDNS (2020)
|
||||
|
||||
contact@happydns.org
|
||||
|
||||
This software is a computer program whose purpose is to provide a modern
|
||||
interface to interact with DNS systems.
|
||||
|
||||
This software is governed by the CeCILL license under French law and abiding
|
||||
by the rules of distribution of free software. You can use, modify and/or
|
||||
redistribute the software under the terms of the CeCILL license as
|
||||
circulated by CEA, CNRS and INRIA at the following URL
|
||||
"http://www.cecill.info".
|
||||
|
||||
As a counterpart to the access to the source code and rights to copy, modify
|
||||
and redistribute granted by the license, users are provided only with a
|
||||
limited warranty and the software's author, the holder of the economic
|
||||
rights, and the successive licensors have only limited liability.
|
||||
|
||||
In this respect, the user's attention is drawn to the risks associated with
|
||||
loading, using, modifying and/or developing or reproducing the software by
|
||||
the user in light of its specific status of free software, that may mean
|
||||
that it is complicated to manipulate, and that also therefore means that it
|
||||
is reserved for developers and experienced professionals having in-depth
|
||||
computer knowledge. Users are therefore encouraged to load and test the
|
||||
software's suitability as regards their requirements in conditions enabling
|
||||
the security of their systems and/or data to be ensured and, more generally,
|
||||
to use and operate it in the same conditions as regards security.
|
||||
|
||||
The fact that you are presently reading this means that you have had
|
||||
knowledge of the CeCILL license and that you accept its terms.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<b-container style="margin-top: 10vh; margin-bottom: 10vh;">
|
||||
<b-alert v-if="error !== null" variant="danger" :show="error.length > 0">
|
||||
{{ error }}
|
||||
</b-alert>
|
||||
|
||||
<div v-if="isLoading" class="text-center">
|
||||
<b-spinner variant="primary" label="Spinning" class="mr-3" /> Please wait
|
||||
</div>
|
||||
|
||||
<b-form v-else-if="user === ''" ref="formMail" @submit.stop.prevent="goSendLink">
|
||||
<p>
|
||||
In order to recover your account, we'll send you an e-mail containing a link that will allow you to redefine your password.
|
||||
</p>
|
||||
<b-form-row>
|
||||
<label for="email-input" class="col-md-4 col-form-label text-truncate text-md-right font-weight-bold">Email address</label>
|
||||
<b-col md="6">
|
||||
<b-form-input
|
||||
id="email-input"
|
||||
ref="signupemail"
|
||||
v-model="email"
|
||||
:state="emailState"
|
||||
required
|
||||
autofocus
|
||||
type="email"
|
||||
placeholder="jPostel@isi.edu"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</b-col>
|
||||
</b-form-row>
|
||||
<b-form-row class="mt-3">
|
||||
<b-button class="offset-sm-4 col-sm-4" type="submit" variant="primary">
|
||||
Send me an e-mail to recover my account
|
||||
</b-button>
|
||||
</b-form-row>
|
||||
</b-form>
|
||||
|
||||
<b-form v-else ref="formRecover" @submit.stop.prevent="goRecover">
|
||||
<p>
|
||||
In order to recover your account, please fill the following form, with a fresh password.
|
||||
</p>
|
||||
<b-form-row>
|
||||
<label for="password-input" class="col-md-4 col-form-label text-truncate text-md-right font-weight-bold">New password</label>
|
||||
<b-col md="6">
|
||||
<b-form-input
|
||||
id="password-input"
|
||||
ref="recoverpassword"
|
||||
v-model="password"
|
||||
type="password"
|
||||
:state="passwordState"
|
||||
required
|
||||
placeholder="xXxXxXxXxX"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</b-col>
|
||||
</b-form-row>
|
||||
<b-form-row class="mt-2">
|
||||
<label for="passwordconfirm-input" class="col-md-4 col-form-label text-truncate text-md-right font-weight-bold">Password confirmation</label>
|
||||
<b-col md="6">
|
||||
<b-form-input
|
||||
id="passwordconfirm-input"
|
||||
ref="recoverpasswordconfirm"
|
||||
v-model="passwordConfirm"
|
||||
type="password"
|
||||
:state="passwordConfirmState"
|
||||
required
|
||||
placeholder="xXxXxXxXxX"
|
||||
/>
|
||||
</b-col>
|
||||
</b-form-row>
|
||||
<b-form-row class="mt-3">
|
||||
<b-button class="offset-sm-4 col-sm-4" type="submit" variant="primary">
|
||||
Redefine my password
|
||||
</b-button>
|
||||
</b-form-row>
|
||||
</b-form>
|
||||
</b-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
email: '',
|
||||
emailState: null,
|
||||
error: null,
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
user: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
isLoading () {
|
||||
return this.error === null || this.user === null
|
||||
},
|
||||
passwordState () {
|
||||
if (this.password.length === 0) {
|
||||
return null
|
||||
}
|
||||
return this.password.length > 15 || (
|
||||
/[A-Z]/.test(this.password) && /[a-z]/.test(this.password) && /[0-9]/.test(this.password) && (/\W/.test(this.password) || this.password.length >= 8))
|
||||
},
|
||||
passwordConfirmState () {
|
||||
if (this.passwordConfirm.length === 0) {
|
||||
return null
|
||||
}
|
||||
return this.password === this.passwordConfirm
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
if (this.$route.query.u) {
|
||||
axios
|
||||
.post('/api/users/' + encodeURIComponent(this.$route.query.u) + '/recovery', {
|
||||
key: this.$route.query.k
|
||||
})
|
||||
.then(
|
||||
(response) => {
|
||||
this.error = ''
|
||||
this.user = this.$route.query.u
|
||||
},
|
||||
(error) => {
|
||||
this.error = error.response.data.errmsg
|
||||
this.user = ''
|
||||
}
|
||||
)
|
||||
} else {
|
||||
this.error = ''
|
||||
this.user = ''
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
goSendLink () {
|
||||
const valid = this.$refs.formMail.checkValidity()
|
||||
this.emailState = valid
|
||||
|
||||
if (valid) {
|
||||
axios
|
||||
.patch('/api/users', {
|
||||
kind: 'recovery',
|
||||
email: this.email
|
||||
})
|
||||
.then(
|
||||
(response) => {
|
||||
this.$root.$bvToast.toast(
|
||||
'Please check your inbox in order to recover your account.', {
|
||||
title: 'Password recovery email send!',
|
||||
autoHideDelay: 5000,
|
||||
variant: 'success',
|
||||
toaster: 'b-toaster-content-right'
|
||||
}
|
||||
)
|
||||
this.$router.push('/')
|
||||
},
|
||||
(error) => {
|
||||
this.$bvToast.toast(
|
||||
error.response.data.errmsg, {
|
||||
title: 'Password recovery problem',
|
||||
autoHideDelay: 5000,
|
||||
variant: 'danger',
|
||||
toaster: 'b-toaster-content-right'
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
goRecover () {
|
||||
const valid = this.$refs.formRecover.checkValidity()
|
||||
|
||||
if (valid && this.user) {
|
||||
axios
|
||||
.post('/api/users/' + encodeURIComponent(this.user) + '/recovery', {
|
||||
key: this.$route.query.k,
|
||||
password: this.password
|
||||
})
|
||||
.then(
|
||||
(response) => {
|
||||
this.$root.$bvToast.toast(
|
||||
'You can now login with your new password.', {
|
||||
title: 'Password redefined successfully!',
|
||||
autoHideDelay: 5000,
|
||||
variant: 'success',
|
||||
toaster: 'b-toaster-content-right'
|
||||
}
|
||||
)
|
||||
this.$router.push('/login')
|
||||
},
|
||||
(error) => {
|
||||
this.$bvToast.toast(
|
||||
error.response.data.errmsg, {
|
||||
title: 'Password recovery problem',
|
||||
autoHideDelay: 5000,
|
||||
variant: 'danger',
|
||||
toaster: 'b-toaster-content-right'
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -89,7 +89,7 @@
|
|||
<b-button type="submit" variant="primary">
|
||||
Go!
|
||||
</b-button>
|
||||
<b-button type="button" variant="outline-dark">
|
||||
<b-button to="/forgotten-password" variant="outline-dark">
|
||||
Forgotten password?
|
||||
</b-button>
|
||||
</div>
|
||||
|
|
|
@ -33,6 +33,7 @@ package happydns
|
|||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
@ -42,11 +43,12 @@ import (
|
|||
)
|
||||
|
||||
type User struct {
|
||||
Id int64
|
||||
Email string
|
||||
Password []byte
|
||||
RegistrationTime *time.Time
|
||||
EmailValidated *time.Time
|
||||
Id int64
|
||||
Email string
|
||||
Password []byte
|
||||
RegistrationTime *time.Time
|
||||
EmailValidated *time.Time
|
||||
PasswordRecoveryKey []byte `json:",omitempty"`
|
||||
}
|
||||
|
||||
type Users []*User
|
||||
|
@ -67,6 +69,7 @@ func NewUser(email string, password string) (u *User, err error) {
|
|||
|
||||
func (u *User) DefinePassword(password string) (err error) {
|
||||
u.Password, err = bcrypt.GenerateFromPassword([]byte(password), 0)
|
||||
u.PasswordRecoveryKey = nil
|
||||
|
||||
return
|
||||
}
|
||||
|
@ -101,3 +104,38 @@ func (u *User) ValidateEmail(key string) error {
|
|||
|
||||
return fmt.Errorf("The validation address link you follow is invalid or has expired (it is valid during %d hours)", RegistrationHashValidity/time.Hour)
|
||||
}
|
||||
|
||||
const AccountRecoveryHashValidity = 2 * time.Hour
|
||||
|
||||
func (u *User) GenAccountRecoveryHash(previous bool) string {
|
||||
if u.PasswordRecoveryKey == nil {
|
||||
u.PasswordRecoveryKey = make([]byte, 64)
|
||||
if _, err := rand.Read(u.PasswordRecoveryKey); err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
date := time.Now()
|
||||
date = date.Truncate(AccountRecoveryHashValidity)
|
||||
if previous {
|
||||
date = date.Add(AccountRecoveryHashValidity * -1)
|
||||
}
|
||||
|
||||
if len(u.PasswordRecoveryKey) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(
|
||||
hmac.New(
|
||||
sha512.New,
|
||||
u.PasswordRecoveryKey,
|
||||
).Sum(date.AppendFormat([]byte{}, time.RFC3339)),
|
||||
)
|
||||
}
|
||||
|
||||
func (u *User) CanRecoverAccount(key string) error {
|
||||
if key == u.GenAccountRecoveryHash(false) || key == u.GenAccountRecoveryHash(true) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("The account recovery link you follow is invalid or has expired (it is valid during %d hours)", AccountRecoveryHashValidity/time.Hour)
|
||||
}
|
||||
|
|
14
static.go
14
static.go
|
@ -151,6 +151,20 @@ func init() {
|
|||
fwd_request(w, r, opts.DevProxy)
|
||||
}
|
||||
})
|
||||
api.Router().GET("/forgotten-password", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
opts := r.Context().Value("opts").(*config.Options)
|
||||
|
||||
if opts.DevProxy == "" {
|
||||
if data, err := Asset("htdocs/dist/index.html"); err != nil {
|
||||
fmt.Fprintf(w, "{\"errmsg\":%q}", err)
|
||||
} else {
|
||||
w.Write(data)
|
||||
}
|
||||
} else {
|
||||
r.URL.Path = "/"
|
||||
fwd_request(w, r, opts.DevProxy)
|
||||
}
|
||||
})
|
||||
api.Router().GET("/join", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
opts := r.Context().Value("opts").(*config.Options)
|
||||
|
||||
|
|
Loading…
Reference in New Issue