comparison pkg/controllers/pwreset.go @ 3956:4f9a1ff2c2ee pwreset-rework

Reworked password reset to be single mailed.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Tue, 16 Jul 2019 12:38:44 +0200
parents ed4820efb7e6
children 6dd9741d6ff7
comparison
equal deleted inserted replaced
3954:cb4fda122321 3956:4f9a1ff2c2ee
19 "bytes" 19 "bytes"
20 "context" 20 "context"
21 "database/sql" 21 "database/sql"
22 "encoding/hex" 22 "encoding/hex"
23 "errors" 23 "errors"
24 "io"
24 "log" 25 "log"
25 "net/http" 26 "net/http"
26 "os/exec" 27 "os/exec"
27 "strconv" 28 "strconv"
28 "strings" 29 "strings"
29 "text/template"
30 "time" 30 "time"
31
32 htmlTemplate "html/template"
33 textTemplate "text/template"
31 34
32 "github.com/gorilla/mux" 35 "github.com/gorilla/mux"
33 36
34 "gemma.intevation.de/gemma/pkg/auth" 37 "gemma.intevation.de/gemma/pkg/auth"
35 "gemma.intevation.de/gemma/pkg/common" 38 "gemma.intevation.de/gemma/pkg/common"
38 "gemma.intevation.de/gemma/pkg/models" 41 "gemma.intevation.de/gemma/pkg/models"
39 ) 42 )
40 43
41 const ( 44 const (
42 insertRequestSQL = `INSERT INTO sys_admin.password_reset_requests 45 insertRequestSQL = `INSERT INTO sys_admin.password_reset_requests
43 (hash, username) VALUES ($1, $2)` 46 (hash, username) VALUES ($1, $2)
47 ON CONFLICT (username) DO UPDATE SET hash = $1`
44 48
45 countRequestsSQL = `SELECT count(*) FROM sys_admin.password_reset_requests` 49 countRequestsSQL = `SELECT count(*) FROM sys_admin.password_reset_requests`
46
47 countRequestsUserSQL = `SELECT count(*) FROM sys_admin.password_reset_requests
48 WHERE username = $1`
49 50
50 deleteRequestSQL = `DELETE FROM sys_admin.password_reset_requests 51 deleteRequestSQL = `DELETE FROM sys_admin.password_reset_requests
51 WHERE hash = $1` 52 WHERE hash = $1`
52 53
53 findRequestSQL = `SELECT lu.email_address, lu.username 54 findRequestSQL = `SELECT lu.username
54 FROM sys_admin.password_reset_requests prr 55 FROM sys_admin.password_reset_requests prr
55 JOIN users.list_users lu on prr.username = lu.username 56 JOIN users.list_users lu on prr.username = lu.username
56 WHERE prr.hash = $1` 57 WHERE prr.hash = $1`
57 58
58 cleanupRequestsSQL = `DELETE FROM sys_admin.password_reset_requests 59 cleanupRequestsSQL = `DELETE FROM sys_admin.password_reset_requests
61 userExistsSQL = `SELECT email_address 62 userExistsSQL = `SELECT email_address
62 FROM users.list_users WHERE username = $1` 63 FROM users.list_users WHERE username = $1`
63 64
64 updatePasswordSQL = `UPDATE users.list_users 65 updatePasswordSQL = `UPDATE users.list_users
65 SET pw = $1 WHERE username = $2` 66 SET pw = $1 WHERE username = $2`
67
68 deletePasswordResetRequestSQL = `
69 DELETE FROM sys_admin.password_reset_requests
70 WHERE username = $1`
66 ) 71 )
67 72
68 const ( 73 const (
69 hashLength = 16 74 hashLength = 16
70 passwordLength = 20 75 passwordLength = 20
75 ) 80 )
76 81
77 const pwResetRole = "sys_admin" 82 const pwResetRole = "sys_admin"
78 83
79 var ( 84 var (
80 errTooMuchPasswordResets = errors.New("Too many password resets") 85 errTooMuchPasswordResets = errors.New("Too many password resets")
81 errTooMuchPasswordResetsPerUser = errors.New("Too many password resets per user") 86 errNoSuchUser = errors.New("User does not exist")
82 errNoSuchUser = errors.New("User does not exist") 87 errInvalidUser = errors.New("Invalid user")
83 errInvalidUser = errors.New("Invalid user")
84 ) 88 )
85 89
86 var ( 90 var (
87 passwordResetRequestMailTmpl = template.Must( 91 passwordResetRequestMailTmpl = textTemplate.Must(
88 template.New("request").Parse(`You or someone else has requested a password change 92 textTemplate.New("request").Parse(`You or someone else has requested a password change
89 for your account {{ .User }} on 93 for your account {{ .User }} on
90 {{ .HTTPS }}://{{ .Server }} 94 {{ .Server }}
91 95
92 Please follow this link to have a new password generated and mailed to you: 96 Please follow this link to have a new password generated:
93 97
94 {{ .HTTPS }}://{{ .Server }}/api/users/passwordreset/{{ .Hash }} 98 {{ .Server }}/api/users/passwordreset/{{ .Hash }}
95 99
96 The link is only valid for 12 hours. 100 The link is only valid for 12 hours.
97 101
98 If you did not initiate this password reset or do not want to reset the 102 If you did not initiate this password reset or do not want to reset the
99 password, just ignore this email. 103 password, just ignore this email.
100 104
101 Best regards 105 Best regards
102 Your service team`)) 106 Your service team`))
103 107
104 passwordResetMailTmpl = template.Must( 108 passwordResetPage = htmlTemplate.Must(
105 template.New("reset").Parse(`Your password for your account {{ .User }} on 109 htmlTemplate.New("page").Parse(`<!DOCTYPE html>
106 {{ .HTTPS }}://{{ .Server }} 110 <html lang="en">
107 111 <head>
108 has been changed to 112 <meta charset="utf-8" />
109 {{ .Password }} 113 <title>Password reset done</title>
110 114 </head>
111 Change it as soon as possible. 115 <body>
112 116 <p>The password reset for user <strong><tt>{{ .User }}</tt></strong> successfully done.</p>
113 Best regards 117 <p>New password: <strong><tt>{{ .Password }}</tt></strong></p>
114 Your service team`)) 118 <p><a href="/">Go to login page.</a></p>
119 </body>
120 </html>
121 `))
115 ) 122 )
116 123
117 func init() { 124 func init() {
118 go removeOutdated() 125 go removeOutdated()
119 } 126 }
135 log.Printf("error: %v\n", err) 142 log.Printf("error: %v\n", err)
136 } 143 }
137 } 144 }
138 } 145 }
139 146
140 func requestMessageBody(https, user, hash, server string) string { 147 func requestMessageBody(user, hash, server string) string {
141 var content = struct { 148 var content = struct {
142 User string 149 User string
143 HTTPS string
144 Server string 150 Server string
145 Hash string 151 Hash string
146 }{ 152 }{
147 User: user, 153 User: user,
148 HTTPS: https,
149 Server: server, 154 Server: server,
150 Hash: hash, 155 Hash: hash,
151 } 156 }
152 var buf bytes.Buffer 157 var buf bytes.Buffer
153 if err := passwordResetRequestMailTmpl.Execute(&buf, &content); err != nil { 158 if err := passwordResetRequestMailTmpl.Execute(&buf, &content); err != nil {
154 log.Printf("error: %v\n", err) 159 log.Printf("error: %v\n", err)
155 } 160 }
156 return buf.String() 161 return buf.String()
157 } 162 }
158 163
159 func changedMessageBody(https, user, password, server string) string { 164 func changedMessageBody(w io.Writer, user, password string) error {
160 var content = struct { 165 var content = struct {
161 User string 166 User string
162 HTTPS string
163 Server string
164 Password string 167 Password string
165 }{ 168 }{
166 User: user, 169 User: user,
167 HTTPS: https,
168 Server: server,
169 Password: password, 170 Password: password,
170 } 171 }
171 var buf bytes.Buffer 172 return passwordResetPage.Execute(w, &content)
172 if err := passwordResetMailTmpl.Execute(&buf, &content); err != nil {
173 log.Printf("error: %v\n", err)
174 }
175 return buf.String()
176 }
177
178 func useHTTPS(req *http.Request) string {
179 if req.Header.Get("X-Use-Protocol") == "https" ||
180 req.URL.Scheme == "https" {
181 return "https"
182 }
183 return "http"
184 } 173 }
185 174
186 func generateHash() string { 175 func generateHash() string {
187 return hex.EncodeToString(common.GenerateRandomKey(hashLength)) 176 return hex.EncodeToString(common.GenerateRandomKey(hashLength))
188 } 177 }
196 } 185 }
197 // Use internal generator. 186 // Use internal generator.
198 return common.RandomString(passwordLength) 187 return common.RandomString(passwordLength)
199 } 188 }
200 189
201 func backgroundRequest(https, host string, user *models.PWResetUser) error { 190 func backgroundRequest(host string, user *models.PWResetUser) error {
202 191
203 if user.User == "" { 192 if user.User == "" {
204 return errInvalidUser 193 return errInvalidUser
205 } 194 }
206 195
228 switch { 217 switch {
229 case err == sql.ErrNoRows: 218 case err == sql.ErrNoRows:
230 return errNoSuchUser 219 return errNoSuchUser
231 case err != nil: 220 case err != nil:
232 return err 221 return err
233 }
234
235 if err := conn.QueryRowContext(
236 ctx, countRequestsUserSQL, user.User).Scan(&count); err != nil {
237 return err
238 }
239
240 // Limit requests per user
241 if count >= maxPasswordRequestsPerUser {
242 return errTooMuchPasswordResetsPerUser
243 } 222 }
244 223
245 hash = generateHash() 224 hash = generateHash()
246 _, err = conn.ExecContext(ctx, insertRequestSQL, hash, user.User) 225 _, err = conn.ExecContext(ctx, insertRequestSQL, hash, user.User)
247 return err 226 return err
248 }, 227 },
249 ); err != nil { 228 ); err != nil {
250 return err 229 return err
251 } 230 }
252 231
253 body := requestMessageBody(https, user.User, hash, host) 232 body := requestMessageBody(user.User, hash, host)
254 233
255 return misc.SendMail(email, "Password Reset Link", body) 234 return misc.SendMail(email, "Password Reset Link", body)
256 }
257
258 // host checks if we are behind a proxy and returns the name
259 // of the up-front server.
260 func host(req *http.Request) string {
261 if fwd := req.Header.Get("X-Forwarded-Host"); fwd != "" {
262 return fwd
263 }
264 return req.Host
265 } 235 }
266 236
267 func passwordResetRequest( 237 func passwordResetRequest(
268 input interface{}, 238 input interface{},
269 req *http.Request, 239 req *http.Request,
270 _ *sql.Conn, 240 _ *sql.Conn,
271 ) (jr JSONResult, err error) { 241 ) (jr JSONResult, err error) {
272 242
273 // We do the checks and the emailing in background 243 // We do the checks and the emailing in background
274 // no reduce the risks of timing attacks. 244 // no reduce the risks of timing attacks.
275 go func(https, host string, user *models.PWResetUser) { 245 go func(user *models.PWResetUser) {
276 if err := backgroundRequest(https, host, user); err != nil { 246 config.WaitReady()
247 host := config.ExternalURL()
248 if err := backgroundRequest(host, user); err != nil {
277 log.Printf("error: %v\n", err) 249 log.Printf("error: %v\n", err)
278 } 250 }
279 }(useHTTPS(req), host(req), input.(*models.PWResetUser)) 251 }(input.(*models.PWResetUser))
280 252
281 // Send a neutral message to avoid being an user oracle. 253 // Send a neutral message to avoid being an user oracle.
282 const neutralMessage = "If this account exists, a reset link will be mailed." 254 const neutralMessage = "If this account exists, a reset link will be mailed."
283 255
284 jr.Result = &struct { 256 jr.Result = &struct {
294 if _, err := hex.DecodeString(hash); err != nil { 266 if _, err := hex.DecodeString(hash); err != nil {
295 http.Error(rw, "invalid hash", http.StatusBadRequest) 267 http.Error(rw, "invalid hash", http.StatusBadRequest)
296 return 268 return
297 } 269 }
298 270
299 var email, user, password string 271 var user, password string
300 272
301 ctx := req.Context() 273 ctx := req.Context()
302 274
303 if err := auth.RunAs( 275 err := auth.RunAs(
304 ctx, pwResetRole, func(conn *sql.Conn) error { 276 ctx, pwResetRole,
305 err := conn.QueryRowContext(ctx, findRequestSQL, hash).Scan(&email, &user) 277 func(conn *sql.Conn) error {
278 tx, err := conn.BeginTx(ctx, nil)
279 if err != nil {
280 return err
281 }
282 defer tx.Rollback()
283
284 err = tx.QueryRowContext(ctx, findRequestSQL, hash).Scan(&user)
306 switch { 285 switch {
307 case err == sql.ErrNoRows: 286 case err == sql.ErrNoRows:
308 return JSONError{http.StatusNotFound, "No such hash"} 287 return errors.New("No such hash")
309 case err != nil: 288 case err != nil:
310 return err 289 return err
311 } 290 }
312 password = generateNewPassword() 291 password = generateNewPassword()
313 res, err := conn.ExecContext(ctx, updatePasswordSQL, password, user) 292 res, err := tx.ExecContext(ctx, updatePasswordSQL, password, user)
314 if err != nil { 293 if err != nil {
315 return err 294 return err
316 } 295 }
317 if n, err2 := res.RowsAffected(); err2 == nil && n == 0 { 296 if n, err2 := res.RowsAffected(); err2 == nil && n == 0 {
318 return JSONError{http.StatusNotFound, "User not found"} 297 return errors.New("User not found")
319 } 298 }
320 _, err = conn.ExecContext(ctx, deleteRequestSQL, hash) 299 if _, err = tx.ExecContext(ctx, deleteRequestSQL, hash); err != nil {
300 return err
301 }
302 return tx.Commit()
303 },
304 )
305
306 switch {
307 case err == sql.ErrNoRows:
308 http.Error(rw, "No such request", http.StatusNotFound)
309 return
310 case err != nil:
311 http.Error(rw, "Error: "+err.Error(), http.StatusInternalServerError)
312 return
313 }
314
315 if err := changedMessageBody(rw, user, password); err != nil {
316 log.Printf("error: %v\n", err)
317 }
318 }
319
320 func deletePasswordResetRequest(user string) {
321 ctx := context.Background()
322 if err := auth.RunAs(
323 ctx,
324 pwResetRole,
325 func(conn *sql.Conn) error {
326 _, err := conn.ExecContext(ctx, deletePasswordResetRequestSQL, user)
321 return err 327 return err
322 }); err == nil { 328 },
323 https := useHTTPS(req) 329 ); err != nil {
324 server := host(req) 330 log.Printf("error: %v\n", err)
325 body := changedMessageBody(https, user, password, server) 331 }
326 if err = misc.SendMail(email, "Password Reset Done", body); err != nil { 332 }
327 log.Printf("error: %v\n", err)
328 http.Error(
329 rw,
330 http.StatusText(http.StatusInternalServerError),
331 http.StatusInternalServerError)
332 return
333 }
334 var url = https + "://" + server
335 http.Redirect(rw, req, url, http.StatusSeeOther)
336 }
337 }