Mercurial > gemma
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 } |