From 85545aba62546f219a9c9730945511412a3174ef Mon Sep 17 00:00:00 2001 From: jloup Date: Fri, 4 May 2018 11:55:15 +0200 Subject: [PATCH] Password reset. --- Gopkg.lock | 36 ++++++++---- Gopkg.toml | 8 +++ api/api.go | 16 +++++ api/auth_jwt.go | 4 -- api/free_sms.go | 26 +++++++++ api/password_reset.go | 103 +++++++++++++++++++++++++++++++++ api/routes.go | 19 ++++++ api/user.go | 7 +++ cmd/ansible/conf.toml.j2 | 3 + cmd/ansible/vars.yml | 50 +++++++++------- cmd/app/conf.toml | 3 + cmd/app/main.go | 11 ++-- cmd/web/Makefile | 2 +- cmd/web/js/api.js | 21 +++++++ cmd/web/js/change_password.jsx | 62 ++++++++++++++++++++ cmd/web/js/main.jsx | 34 +++++++++++ cmd/web/js/password_reset.jsx | 57 ++++++++++++++++++ cmd/web/package.json | 1 + cmd/web/yarn.lock | 4 ++ db/user.go | 10 ++++ 20 files changed, 433 insertions(+), 44 deletions(-) create mode 100644 api/free_sms.go create mode 100644 api/password_reset.go create mode 100644 cmd/web/js/change_password.jsx create mode 100644 cmd/web/js/password_reset.jsx diff --git a/Gopkg.lock b/Gopkg.lock index d8ceccf..91f891c 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -23,6 +23,18 @@ revision = "3cfea5ab600ae37946be2b763b8ec2c1cf2d272d" version = "v1.0.0" +[[projects]] + branch = "master" + name = "github.com/dchest/authcookie" + packages = ["."] + revision = "fbdef6e998665bcb27a2227f39d840dbfc62a918" + +[[projects]] + branch = "master" + name = "github.com/dchest/passwordreset" + packages = ["."] + revision = "642cf836ac8dfd4ddec7a28cbef7b00da0807812" + [[projects]] name = "github.com/dgrijalva/jwt-go" packages = ["."] @@ -67,8 +79,8 @@ "orm", "types" ] - revision = "24dfe0572921e42ffe1035f7afbd40f9d97cb8c8" - version = "v6.10.0" + revision = "5b73ce88484575f3480edf393237f6bf79d5f166" + version = "v6.11.2" [[projects]] name = "github.com/go-redis/redis" @@ -88,8 +100,8 @@ [[projects]] name = "github.com/golang/protobuf" packages = ["proto"] - revision = "925541529c1fa6821df4e44ce2723319eb2be768" - version = "v1.0.0" + revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265" + version = "v1.1.0" [[projects]] branch = "master" @@ -134,8 +146,8 @@ [[projects]] name = "github.com/ugorji/go" packages = ["codec"] - revision = "9831f2c3ac1068a78f50999a30db84270f647af6" - version = "v1.1" + revision = "b4c50a2b199d93b13dc15e78929cfb23bfdf21ab" + version = "v1.1.1" [[projects]] branch = "master" @@ -145,13 +157,13 @@ "blowfish", "ssh/terminal" ] - revision = "88942b9c40a4c9d203b82b3731787b672d6e809b" + revision = "8b1d31080a7692e075c4681cb2458454a1fe0706" [[projects]] branch = "master" name = "golang.org/x/net" packages = ["websocket"] - revision = "6078986fec03a1dcc236c34816c71b0e05018fda" + revision = "640f4622ab692b87c2f3a94265e6f579fe38263d" [[projects]] branch = "master" @@ -160,7 +172,7 @@ "unix", "windows" ] - revision = "91ee8cde435411ca3f1cd365e8f20131aed4d0a1" + revision = "78d5f264b493f125018180c204871ecf58a2dce1" [[projects]] name = "gopkg.in/go-playground/validator.v8" @@ -171,12 +183,12 @@ [[projects]] name = "gopkg.in/yaml.v2" packages = ["."] - revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5" - version = "v2.1.1" + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "587bbc93998cd884863b3559ec6ef87ebf381b7bca10587a7e79c2c227c64ead" + inputs-digest = "5c987f56ef837352173d0a50f12d6c58ac72831b5e90d34ca0a283bee71fb1a2" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 9721956..5926748 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -68,3 +68,11 @@ [[constraint]] name = "github.com/go-redis/redis" version = "6.10.2" + +[[constraint]] + branch = "master" + name = "github.com/dchest/passwordreset" + +[[override]] + name = "github.com/dchest/authcookie" + branch = "master" diff --git a/api/api.go b/api/api.go index 7b7be49..42b9923 100644 --- a/api/api.go +++ b/api/api.go @@ -7,6 +7,22 @@ import ( "github.com/gin-gonic/gin" ) +var CONFIG Config + +type Config struct { + JwtSecret string `toml:"jwt_secret"` + PasswordResetSecret string `toml:"password_reset_secret"` + FreeSMSUser string `toml:"free_sms_user"` + FreeSMSPass string `toml:"free_sms_pass"` +} + +func SetConfig(config Config) { + CONFIG = config + + JWT_SECRET = []byte(config.JwtSecret) + PASSWORD_RESET_SECRET = []byte(config.PasswordResetSecret) +} + type Error struct { Code ErrorCode UserMessage string diff --git a/api/auth_jwt.go b/api/auth_jwt.go index 5ce1593..db7e3f4 100644 --- a/api/auth_jwt.go +++ b/api/auth_jwt.go @@ -20,10 +20,6 @@ type JwtClaims struct { jwt.StandardClaims } -func SetJwtSecretKey(secret string) { - JWT_SECRET = []byte(secret) -} - func VerifyJwtToken(token string) (JwtClaims, error) { if len(JWT_SECRET) == 0 { return JwtClaims{}, fmt.Errorf("not initialized jwt secret") diff --git a/api/free_sms.go b/api/free_sms.go new file mode 100644 index 0000000..f09a1d1 --- /dev/null +++ b/api/free_sms.go @@ -0,0 +1,26 @@ +package api + +import ( + "fmt" + "net/http" + "net/url" +) + +func SendSMS(user, pass, msg string) error { + form := url.Values{ + "user": []string{user}, + "pass": []string{pass}, + "msg": []string{msg}, + } + + response, err := http.Get(fmt.Sprintf("https://smsapi.free-mobile.fr/sendmsg?%s", form.Encode())) + if err != nil { + return err + } + + if response.StatusCode != 200 { + return fmt.Errorf("Cannot send sms: status code %v", response.StatusCode) + } + + return nil +} diff --git a/api/password_reset.go b/api/password_reset.go new file mode 100644 index 0000000..82aaaef --- /dev/null +++ b/api/password_reset.go @@ -0,0 +1,103 @@ +package api + +import ( + "fmt" + "time" + + "github.com/dchest/passwordreset" + "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db" +) + +var PASSWORD_RESET_SECRET []byte + +type PasswordResetQuery struct { + In struct { + Email string + } +} + +func (q PasswordResetQuery) ValidateParams() *Error { + if q.In.Email == "" { + return &Error{InvalidEmail, "invalid email", fmt.Errorf("invalid email")} + } + + return nil +} + +func (q PasswordResetQuery) Run() (interface{}, *Error) { + user, err := db.GetUserByEmail(q.In.Email) + if err != nil { + return nil, NewInternalError(err) + } + + if user == nil { + return nil, &Error{NotFound, "account not found", fmt.Errorf("'%v' is not registered", q.In.Email)} + } + + token := passwordreset.NewToken(q.In.Email, time.Hour*24*1, []byte(user.PasswordHash), PASSWORD_RESET_SECRET) + if CONFIG.FreeSMSUser != "" { + err := SendSMS(CONFIG.FreeSMSUser, CONFIG.FreeSMSPass, fmt.Sprintf("'%v' request a password reset. Token '/change-password?token=%v'", q.In.Email, token)) + if err != nil { + return nil, NewInternalError(err) + } + } + + return "OK", nil +} + +type ChangePasswordQuery struct { + In struct { + Token string + Password string + } +} + +func (q ChangePasswordQuery) ValidateParams() *Error { + if q.In.Password == "" { + return &Error{InvalidPassword, "invalid password", fmt.Errorf("invalid password")} + } + + if q.In.Token == "" { + return &Error{BadRequest, "invalid token", fmt.Errorf("invalid token")} + } + + return nil +} + +func (q ChangePasswordQuery) Run() (interface{}, *Error) { + var user *db.User + + email, err := passwordreset.VerifyToken(q.In.Token, func(email string) ([]byte, error) { + var err error + user, err = db.GetUserByEmail(email) + if err != nil { + return nil, err + } + + if user == nil { + return nil, fmt.Errorf("'%v' is not registered", email) + } + + return []byte(user.PasswordHash), nil + + }, PASSWORD_RESET_SECRET) + + if err != nil && (err == passwordreset.ErrExpiredToken) { + return nil, &Error{BadRequest, "expired token", fmt.Errorf("expired token")} + } else if err != nil && (err == passwordreset.ErrMalformedToken || err == passwordreset.ErrWrongSignature) { + return nil, &Error{BadRequest, "wrong token", fmt.Errorf("wrong token")} + } else if err != nil { + return nil, NewInternalError(err) + } + + if user == nil { + return nil, &Error{BadRequest, "bad request", fmt.Errorf("no user found for email '%v'", email)} + } + + err = db.SetPassword(user, q.In.Password) + if err != nil { + return nil, NewInternalError(err) + } + + return "OK", nil +} diff --git a/api/routes.go b/api/routes.go index cdf3dd9..22af0e7 100644 --- a/api/routes.go +++ b/api/routes.go @@ -25,6 +25,8 @@ var Groups = []Group{ []Route{ {"POST", []gin.HandlerFunc{Signup}, "/signup"}, {"POST", []gin.HandlerFunc{Signin}, "/signin"}, + {"POST", []gin.HandlerFunc{PasswordReset}, "/passwordreset"}, + {"POST", []gin.HandlerFunc{ChangePassword}, "/changepassword"}, }, }, { @@ -132,3 +134,20 @@ func UpdateMarketConfig(c *gin.Context) { RunQuery(query, c) } + +func PasswordReset(c *gin.Context) { + query := &PasswordResetQuery{} + + query.In.Email = c.PostForm("email") + + RunQuery(query, c) +} + +func ChangePassword(c *gin.Context) { + query := &ChangePasswordQuery{} + + query.In.Token = c.PostForm("token") + query.In.Password = c.PostForm("password") + + RunQuery(query, c) +} diff --git a/api/user.go b/api/user.go index 1dc69e4..9fd9479 100644 --- a/api/user.go +++ b/api/user.go @@ -74,6 +74,13 @@ func (q SignupQuery) Run() (interface{}, *Error) { return nil, NewInternalError(fmt.Errorf("cannot create jwt token %v", err)) } + if CONFIG.FreeSMSUser != "" { + err := SendSMS(CONFIG.FreeSMSUser, CONFIG.FreeSMSPass, fmt.Sprintf("New user signup '%v'", q.In.Email)) + if err != nil { + return nil, NewInternalError(err) + } + } + return SignResult{token}, nil } diff --git a/cmd/ansible/conf.toml.j2 b/cmd/ansible/conf.toml.j2 index b2c3137..7b048af 100644 --- a/cmd/ansible/conf.toml.j2 +++ b/cmd/ansible/conf.toml.j2 @@ -17,6 +17,9 @@ database=0 [api] domain="{{ app_domain }}" jwt_secret="{{ jwt_secret }}" +password_reset_secret="{{ reset_password_reset }}" +free_sms_user="20996747" +free_sms_pass="bM2ZPETB4zzWg3" [app] public_dir="/var/cryptoportfolio-app/static" diff --git a/cmd/ansible/vars.yml b/cmd/ansible/vars.yml index 1de7413..59a5eb8 100644 --- a/cmd/ansible/vars.yml +++ b/cmd/ansible/vars.yml @@ -1,22 +1,30 @@ $ANSIBLE_VAULT;1.1;AES256 -63613535333830393037646665363566636635366534636261623839326130663431653839346266 -3832643338623561313362663837323234663537663439350a313034326663383235663964626132 -38343964396265323539396439383731336464393337383833653666643736303539626136383431 -6536316338376538360a343862626636363031353037626462333364623433613861393137353336 -37396664663030363530333364633266653862393538313835326138663465626638326363656561 -30393836386664633834663838666432383836623432363936343635313835303166393531643966 -33313361383565363232373066306534613465386534386266306564383365373762613361366365 -61366530623863623336643531346463323233323539333139336335383439373132373233663031 -39666535633362383135376534376532333663636136366130653762643164333436313261646137 -37353139633361636163326366616234613466393731373631616138386263383131663537633533 -31393763316561623134623063623735356334363833623939313437386330323837626131356332 -30383863373535366137366138633832623566613061313138396539306536633763633934313562 -35383763653532336539346632623935303634353866636264373262363839326439313837313765 -36303539613734646238636432393166616438666665363363323331373437633362613838653564 -64393639346661646333383466363162633638643838386666383564366665656266333836363435 -35643231323362323566303535303561626139333830393538383635326631656666323166343863 -31393566346531653535393738326166303261376238316532373833616432306638326139353234 -32653132323764316231393634663262313765393230656232343833373438636430643663353965 -36333931303731646333316430646534383531313264353936396565336338663530303434643036 -34356663373533663137636235386164646334356262336464363862643332636661313339303531 -35663833656564393331636139663738323834373862623436633666306661373166 +66653734343938333339346336333430336239376539663338333431626461656430393931353165 +3564303163396337646466376265633537636362346434330a376630333634366165353832613063 +36353931626633306565613862396566353138353439666336376231643432343637613636633634 +6531666131343361340a633839363864643037373638356433613830666137626665343362303363 +64323766333830646463313130316234316161383031646132346633316162366335643137663835 +63663866613335643330653839393762373163366530376538643666653630636462363535613137 +63663966343335346564316462323463666130313733396164663761613966366165653162663432 +66616232633733613766336465336632373765326362383035613465303463306330623632356232 +38636136633661323137336431646666323464383033346239633032336561613433646632363564 +33323737313235313463643866373465366436376138303430633535623335393163343862366264 +35626263336462353264656637643437326635633237333932613666646666313235396561663762 +36373762363662613635633065383037336634323665333865363533616136373565633436653566 +34393033356566663433343864623664386330376339653463623834393036643733666237393964 +32316134653666306134623135636664643461303831376336373839356531663764636433323539 +62663938363437623235383666363163353562646165633564323635343534316130663062373332 +39303239363166366538623763396563616664663038346465336263666365306435376364366337 +66363137303731613638323839393731326539356262626634373136316265323937303863363637 +37313565663534353035313834626139353333386337643263663264376238393030386363613435 +33616432383131303761306265653566306266366263616162323363383365333363363334653132 +32343235666434636361656133636332303131313766326665393233316135323566633433303362 +62383431313861323036616331633134386238313034633936353931313838383038373435653235 +32333230646439613134613337333762313062613839613232663037363761353664373935356264 +61386239366330653939613637333264313532666363626633386632376138643265373432373233 +39313234326430663561343961313732666465613939343839663263353964663963356639633231 +64663330323761393932613039326339643737303939363033333666346439393631623038316561 +34313233626666663234613339306539343030616266333565326565346563353739613363363464 +65323763653436303030643765663739383965313335373265636336633762636134653536326364 +65353366643639663765663566663065316334383463343464366662373939343763356333343731 +6361 diff --git a/cmd/app/conf.toml b/cmd/app/conf.toml index 13e3e0b..16087e5 100644 --- a/cmd/app/conf.toml +++ b/cmd/app/conf.toml @@ -16,6 +16,9 @@ database=0 [api] domain="localhost" jwt_secret="secret" +password_reset_secret="resetsecret" +free_sms_user="20996747" +free_sms_pass="bM2ZPETB4zzWg3" [app] public_dir="../web/build/static" diff --git a/cmd/app/main.go b/cmd/app/main.go index 65e8b5a..a0463d2 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -21,8 +21,8 @@ type AppConfig struct { } type ApiConfig struct { - Domain string `toml:"domain"` - JwtSecret string `toml:"jwt_secret"` + api.Config + Domain string `toml:"domain"` } type Config struct { @@ -45,9 +45,6 @@ func (c *Config) SetToDefaults() { App: AppConfig{ PublicDir: "./public", }, - Api: ApiConfig{ - JwtSecret: "secret", - }, } c.LogConfiguration.SetToDefaults() @@ -63,7 +60,7 @@ func init() { panic(err) } - api.SetJwtSecretKey(C.Api.JwtSecret) + api.SetConfig(C.Api.Config) db.Init(C.Db, C.Redis) @@ -142,6 +139,8 @@ func main() { "/", "/signup", "/signin", + "/reset-password", + "/change-password", "/signout", "/me", "/otp/enroll", diff --git a/cmd/web/Makefile b/cmd/web/Makefile index c6bc2bd..c5d5d62 100644 --- a/cmd/web/Makefile +++ b/cmd/web/Makefile @@ -4,7 +4,7 @@ export PATH := $(PATH):./node_modules/.bin SRC_DIR=js BUILD_DIR=build/js -JSX_SRC= main.jsx signup.jsx signin.jsx otp.jsx poloniex.jsx +JSX_SRC= main.jsx signup.jsx signin.jsx otp.jsx poloniex.jsx password_reset.jsx change_password.jsx JS_SRC= cookies.js app.js api.js STATIC_FILES= index.html style.css JSX_OBJS=$(addprefix $(BUILD_DIR)/,$(JSX_SRC:.jsx=.js)) diff --git a/cmd/web/js/api.js b/cmd/web/js/api.js index 5cbf5eb..c9b4ef5 100644 --- a/cmd/web/js/api.js +++ b/cmd/web/js/api.js @@ -43,6 +43,27 @@ var ApiEndpoints = { return '/signin'; } }, + 'RESET_PASSWORD': { + 'type': 'POST', + 'auth': false, + 'parameters': [ + {'name': 'email', 'mandatory': true, 'inquery': true}, + ], + 'buildUrl': function() { + return '/passwordreset'; + } + }, + 'CHANGE_PASSWORD': { + 'type': 'POST', + 'auth': false, + 'parameters': [ + {'name': 'token', 'mandatory': true, 'inquery': true}, + {'name': 'password', 'mandatory': true, 'inquery': true}, + ], + 'buildUrl': function() { + return '/changepassword'; + } + }, 'MARKET': { 'type': 'GET', 'auth': true, diff --git a/cmd/web/js/change_password.jsx b/cmd/web/js/change_password.jsx new file mode 100644 index 0000000..aedf4af --- /dev/null +++ b/cmd/web/js/change_password.jsx @@ -0,0 +1,62 @@ +import Api from './api.js'; +import App from './app.js'; +import classNames from 'classnames'; +import React from 'react'; + +class ChangePasswordForm extends React.Component { + constructor(props) { + super(props); + this.state = {'hideMsg': true, 'msg': '', 'msgOk': false, 'password': ''}; + } + + handleSubmit = (e) => { + Api.Call( + 'CHANGE_PASSWORD', + { + 'password': this.state.password, + 'token': this.props.token + }, + function(err, status, data) { + if (err) { + console.error(err, data); + this.displayMessage(App.errorCodeToMessage(err.code), false); + return; + } + + this.displayMessage('You password has been reset.', true); + this.props.onSuccess(); + + }.bind(this) +); + e.preventDefault(); + } + + handlePasswordChange = (event) => { + this.setState({'password': event.target.value}); + } + + hideMessage = () => { + this.setState({'hideMsg': true}); + } + + displayMessage = (msg, ok) => { + this.setState({'msg': msg, 'msgOk': ok, 'hideMsg': false}); + } + + render = () => { + var cName = classNames('form-message', {'hidden': this.state.hideMsg, 'message-ok': this.state.msgOk}); + return ( +
+
+
+ + +
{this.state.msg}
+
+
+
+ ); + } +} + +export default ChangePasswordForm; diff --git a/cmd/web/js/main.jsx b/cmd/web/js/main.jsx index e64adc7..909f1bd 100644 --- a/cmd/web/js/main.jsx +++ b/cmd/web/js/main.jsx @@ -1,11 +1,14 @@ import SignupForm from './signup.js'; import SigninForm from './signin.js'; +import PasswordResetForm from './password_reset.js'; +import ChangePasswordForm from './change_password.js'; import OtpEnrollForm from './otp.js'; import PoloniexController from './poloniex.js'; import App from './app.js'; import Api from './api.js'; import cookies from './cookies.js'; import React from 'react'; +import qs from 'qs'; class Header extends React.Component { render = () => { @@ -60,6 +63,37 @@ App.page('/signin', false, function(context) { ); }); +App.page('/reset-password', false, function(context) { + if (App.isUserSignedIn()) { + App.go('/me'); + return; + } + + App.mount(
+
+ +
); +}); + +App.page('/change-password', false, function(context) { + if (App.isUserSignedIn()) { + App.go('/me'); + return; + } + + var token = qs.parse(context.querystring).token; + + if (token === undefined) { + App.go('/'); + return; + } + + App.mount(
+
+ +
); +}); + App.page('/signout', true, function(context) { cookies.removeItem('jwt'); diff --git a/cmd/web/js/password_reset.jsx b/cmd/web/js/password_reset.jsx new file mode 100644 index 0000000..8cbdc60 --- /dev/null +++ b/cmd/web/js/password_reset.jsx @@ -0,0 +1,57 @@ +import Api from './api.js'; +import App from './app.js'; +import classNames from 'classnames'; +import React from 'react'; + +class PasswordResetForm extends React.Component { + constructor(props) { + super(props); + this.state = {'hideMsg': true, 'msg': '', 'msgOk': false, 'email': ''}; + } + + handleSubmit = (e) => { + Api.Call('RESET_PASSWORD', {'email': this.state.email}, function(err, status, data) { + if (err) { + console.error(err, data); + this.displayMessage(App.errorCodeToMessage(err.code), false); + return; + } + + this.displayMessage('You will receive a reset link to reset your password.', true); + if (this.props.onSuccess) { + this.props.onSuccess(); + } + + }.bind(this)); + e.preventDefault(); + } + + handleEmailChange = (event) => { + this.setState({'email': event.target.value}); + } + + hideMessage = () => { + this.setState({'hideMsg': true}); + } + + displayMessage = (msg, ok) => { + this.setState({'msg': msg, 'msgOk': ok, 'hideMsg': false}); + } + + render = () => { + var cName = classNames('form-message', {'hidden': this.state.hideMsg, 'message-ok': this.state.msgOk}); + return ( +
+
+
+ + +
{this.state.msg}
+
+
+
+ ); + } +} + +export default PasswordResetForm; \ No newline at end of file diff --git a/cmd/web/package.json b/cmd/web/package.json index c9241f1..a867313 100644 --- a/cmd/web/package.json +++ b/cmd/web/package.json @@ -14,6 +14,7 @@ "localenvify": "^1.0.1", "page": "^1.8.3", "path-to-regexp": "^1.2.1", + "qs": "^6.5.1", "react": "^16.2.0" }, "devDependencies": { diff --git a/cmd/web/yarn.lock b/cmd/web/yarn.lock index 0d162a9..b2218ee 100644 --- a/cmd/web/yarn.lock +++ b/cmd/web/yarn.lock @@ -3536,6 +3536,10 @@ q@~1.0.0, q@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/q/-/q-1.0.1.tgz#11872aeedee89268110b10a718448ffb10112a14" +qs@^6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" + qs@~1.2.0: version "1.2.2" resolved "https://registry.yarnpkg.com/qs/-/qs-1.2.2.tgz#19b57ff24dc2a99ce1f8bdf6afcda59f8ef61f88" diff --git a/db/user.go b/db/user.go index aed0ac1..7a0a32b 100644 --- a/db/user.go +++ b/db/user.go @@ -70,3 +70,13 @@ func SetOtpSecret(user *User, secret string, temporary bool) error { return DB.Update(user) } + +func SetPassword(user *User, password string) error { + var err error + user.PasswordHash, err = HashPassword(password) + if err != nil { + return err + } + + return DB.Update(user) +} -- 2.41.0