diff --git a/api/api.go b/api/api.go index b53a1ae74271850b955468bad734677580357be0..07679ce59ce3e31939ef8825714a448f34a8e616 100644 --- a/api/api.go +++ b/api/api.go @@ -21,7 +21,7 @@ func initDB(dbPath string) (*gorm.DB, error) { } db.AutoMigrate(&Member{}, &Product{}, &Purchase{}, &Topup{}, &Transaction{}, - &OrderPurchase{}, &Order{}) + &OrderPurchase{}, &Order{}, &PasswordReset{}) return db, err } @@ -33,12 +33,16 @@ func Init(dbPath string, signKey string, mail *Mail, r *mux.Router) error { a := api{db, []byte(signKey), mail} go a.refundOrders() + go a.cleanPaswordResets() token, err := a.newToken(0, "admin", false) log.Print(token) r.HandleFunc("/signin", a.SignIn).Methods("POST") r.HandleFunc("/token", a.GetToken).Methods("GET") + r.HandleFunc("/reset", a.SendPasswordReset).Methods("POST") + r.HandleFunc("/reset/{token}", a.ValidatePasswordReset).Methods("GET") + r.HandleFunc("/reset/{token}", a.PasswordReset).Methods("PUT") r.HandleFunc("/member", a.authAdmin(a.ListMembers)).Methods("GET") r.HandleFunc("/member", a.authAdmin(a.AddMember)).Methods("POST") diff --git a/api/auth.go b/api/auth.go index b91f4c26a377fb3e2a58fd32cfc67a4da4e36b61..53de1d26f5c09980c3ddb2e25900a522c58306d2 100644 --- a/api/auth.go +++ b/api/auth.go @@ -3,21 +3,45 @@ package api import ( "crypto/rand" "crypto/subtle" + "encoding/base64" "encoding/json" + "errors" "log" "net/http" + "net/url" "time" "github.com/dgrijalva/jwt-go" + "github.com/gorilla/mux" "golang.org/x/crypto/argon2" + "gorm.io/gorm" ) +const ( + timeExpireResetToken = 2 * 24 * time.Hour +) + +type PasswordReset struct { + gorm.Model + Token string `gorm:"unique;index"` + MemberNum int `gorm:"column:member"` + Member *Member `gorm:"foreignKey:MemberNum;references:Num"` +} + type creds struct { Login string `json:"login"` Password string `json:"password"` NoExpire bool `json:"noExpire"` } +type passwordResetPost struct { + Email string `json:"email"` +} + +type passwordResetPut struct { + Password string `json:"password"` +} + func (a *api) SignIn(w http.ResponseWriter, req *http.Request) { var c creds err := json.NewDecoder(req.Body).Decode(&c) @@ -95,6 +119,152 @@ func (a *api) GetToken(w http.ResponseWriter, req *http.Request) { } } +func (a *api) SendPasswordReset(w http.ResponseWriter, req *http.Request) { + var reset passwordResetPost + err := json.NewDecoder(req.Body).Decode(&reset) + if err != nil { + log.Printf("Can't decode password reset request: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + var member Member + err = a.db.Where("email = ?", reset.Email).First(&member).Error + if err != nil { + log.Printf("Can't locate user %s: %v", reset.Email, err) + w.WriteHeader(http.StatusBadRequest) + return + } + + tokenBytes := make([]byte, 15) + _, err = rand.Read(tokenBytes) + if err != nil { + log.Printf("Can't generate a random token for password reset: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + token := base64.URLEncoding.EncodeToString(tokenBytes) + passwordReset := PasswordReset{ + Token: token, + MemberNum: member.Num, + } + url := url.URL{ + Scheme: req.URL.Scheme, + Host: req.URL.Host, + Path: "/api/reset/" + token, + } + err = a.db.Transaction(func(tx *gorm.DB) error { + err = tx.Create(&passwordReset).Error + if err != nil { + return err + } + return a.mail.sendPasswordReset(member, url.String()) + }) + if err != nil { + log.Printf("Error sending password reset: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Write([]byte("Email sent")) + w.WriteHeader(http.StatusCreated) +} + +func (a *api) ValidatePasswordReset(w http.ResponseWriter, req *http.Request) { + passwordReset, status := a.getPasswordReset(req) + if status != http.StatusOK { + w.WriteHeader(status) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err := json.NewEncoder(w).Encode(passwordReset.Member) + if err != nil { + log.Printf("Can't encode reset member: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func (a *api) PasswordReset(w http.ResponseWriter, req *http.Request) { + passwordReset, status := a.getPasswordReset(req) + if status != http.StatusOK { + w.WriteHeader(status) + return + } + var reset passwordResetPut + err := json.NewDecoder(req.Body).Decode(&reset) + if err != nil { + log.Printf("Can't decode password reset put: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + hash, salt, err := newHashPass(reset.Password) + if err != nil { + log.Printf("Can't hash password: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = a.db.Transaction(func(tx *gorm.DB) error { + err := a.db.Model(&Member{}). + Updates(Member{ + PassHash: hash, + Salt: salt, + }).Error + if err != nil { + return err + } + return a.db.Delete(passwordReset).Error + }) + if err != nil { + log.Printf("Error updating password: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Write([]byte("Email sent")) + w.WriteHeader(http.StatusAccepted) +} + +func (a *api) getPasswordReset(req *http.Request) (PasswordReset, int) { + vars := mux.Vars(req) + token := vars["token"] + + var passwordReset PasswordReset + err := a.db.Where("token = ?", token). + Preload("Member"). + First(&passwordReset).Error + status := http.StatusOK + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + status = http.StatusNotFound + } else { + log.Printf("Can't get password reset %s: %v", token, err) + status = http.StatusInternalServerError + } + } + return passwordReset, status +} + +func (a *api) cleanPaswordResets() { + time.Sleep(time.Minute) + const refundSleeptime = 10 * time.Minute + for { + time.Sleep(refundSleeptime) + a.cleanReset() + } +} + +func (a *api) cleanReset() { + t := time.Now().Add(timeExpireResetToken) + res := a.db.Where("created_at < ?", true, t). + Delete(&PasswordReset{}) + if res.Error != nil { + log.Println("Error deleting old reset tokens:", res.Error) + } else if res.RowsAffected != 0 { + log.Println("Deleted", res.RowsAffected, "password reset tokens") + } +} + func (a *api) auth(fn func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, req *http.Request) { token := req.Header.Get("x-authentication") @@ -223,7 +393,7 @@ func (a *api) validateToken(token string) (bool, jwt.MapClaims) { } func newHashPass(password string) (hash []byte, salt []byte, err error) { - salt = make([]byte, 32) + salt = make([]byte, 16) _, err = rand.Read(salt) if err != nil { return diff --git a/api/mail.go b/api/mail.go index a4924d1585f049c09d9cf96451e0245b402a9d35..7d904a35205718ebd4dcd26748d2ef043ba44075 100644 --- a/api/mail.go +++ b/api/mail.go @@ -11,9 +11,11 @@ const ( orderTmpl = `To: {{.To}} From: {{.From}} Content-Type: text/plain; charset="utf-8" -Subject: [garbanzo] pedido {{.Name}} +Subject: [garbanzo] pedido {{.OrderName}} -El pedido {{.Name}} a sido cerrado. +Hola {{.MemberName}}, + +El pedido {{.OrderName}} a sido cerrado. Se han pedido:{{range $name, $amount := .Products}} * {{$name}}: {{$amount}}{{end}} @@ -23,6 +25,24 @@ Las siguientes personas han pedido: {{$name}}:{{range $purchases}} * {{.Product.Name}}: {{.Amount}}{{end}} {{end}} + +Salud y garbancicos. +` + resetTmpl = `To: {{.To}} +From: {{.From}} +Content-Type: text/plain; charset="utf-8" +Subject: [garbanzo] recupera tu contraseña + +Hola {{.Name}}, + +Hemos recivido una petición para recuperar tu contraseña en el Garbanzo Negro. +Si no has pedido cambiar tu contraseña ignora este email o si siguen llegandote +emails como este informa a las administradoras. + +Para cambiar tu contraseña visita el siguiente enlace y sigue las instrucciones: +{{.Link}} + +Salud y garbancicos. ` ) @@ -46,11 +66,12 @@ func NewMail(email, password, server string) *Mail { } type orderData struct { - To string - From string - Name string - Products map[string]int - Purchases map[string][]OrderPurchase + To string + From string + MemberName string + OrderName string + Products map[string]int + Purchases map[string][]OrderPurchase } func (m Mail) sendOrder(to string, order *Order) error { @@ -72,11 +93,12 @@ func (m Mail) sendOrder(to string, order *Order) error { purchases[t.Member.Name] = purchase } data := orderData{ - To: to, - From: m.email, - Name: order.Name, - Products: products, - Purchases: purchases, + To: to, + From: m.email, + OrderName: order.Name, + MemberName: order.Member.Name, + Products: products, + Purchases: purchases, } var buff bytes.Buffer @@ -86,3 +108,30 @@ func (m Mail) sendOrder(to string, order *Order) error { } return smtp.SendMail(m.server, m.auth, m.email, []string{to}, buff.Bytes()) } + +type passwordResetData struct { + To string + From string + Name string + Link string +} + +func (m Mail) sendPasswordReset(member Member, link string) error { + if m.server == "" { + return nil + } + + var buff bytes.Buffer + data := passwordResetData{ + To: member.Email, + From: m.email, + Name: member.Name, + Link: link, + } + + err := m.tmpl.ExecuteTemplate(&buff, "order", data) + if err != nil { + return err + } + return smtp.SendMail(m.server, m.auth, m.email, []string{member.Email}, buff.Bytes()) +}