Files
services/user/handler/handler.go
Daniel Joudat a2b7981d63 fix validatePostUserData (#365)
* fix validatePostUserData

* fix validPostUserData part 2 and users/Update
2022-02-24 08:07:32 +00:00

626 lines
17 KiB
Go

package handler
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
"github.com/micro/micro/v3/service/store"
pauth "github.com/micro/services/pkg/auth"
adminpb "github.com/micro/services/pkg/service/proto"
"golang.org/x/crypto/bcrypt"
otp "github.com/micro/services/otp/proto"
"github.com/micro/services/user/domain"
pb "github.com/micro/services/user/proto"
)
const (
x = "cruft123"
)
var (
alphanum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
emailFormat = regexp.MustCompile("^[\\w-\\.\\+]+@([\\w-]+\\.)+[\\w-]{2,4}$")
)
// random generate i length alphanum string
func random(i int) string {
bytes := make([]byte, i)
for {
rand.Read(bytes)
for i, b := range bytes {
bytes[i] = alphanum[b%byte(len(alphanum))]
}
return string(bytes)
}
return "ughwhy?!!!"
}
type User struct {
domain *domain.Domain
Otp otp.OtpService
}
type userPayload struct {
Id string
Email string
Username string
}
func NewUser(st store.Store, otp otp.OtpService) *User {
return &User{
domain: domain.New(st),
Otp: otp,
}
}
// validatePostUserData trims leading and trailing spaces of userId, username, email
// also, check for email format and make sure that values are not empty.
func (s *User) validatePostUserData(ctx context.Context, p *userPayload) error {
p.Username = strings.TrimSpace(strings.ToLower(p.Username))
p.Email = strings.TrimSpace(strings.ToLower(p.Email))
p.Id = strings.TrimSpace(p.Id)
// email format check
if !emailFormat.MatchString(p.Email) {
return errors.BadRequest("users-email-format-check", "email has wrong format")
}
if p.Id == "" || p.Username == "" || p.Email == "" {
return errors.BadRequest("users-valid-check", "missing id or username or email")
}
return nil
}
func (s *User) Create(ctx context.Context, req *pb.CreateRequest, rsp *pb.CreateResponse) error {
check := func(err error) error {
if err != nil && err.Error() != domain.ErrNotFound.Error() {
return err
}
return nil
}
if len(req.Password) < 8 {
return errors.InternalServerError("users-password-check", "Password is less than 8 characters")
}
// based on the docs Id is optional, hence the need to provide
// one in case of absence
if req.Id == "" {
req.Id = uuid.New().String()
}
p := &userPayload{Id: req.Id, Email: req.Email, Username: req.Username}
if err := s.validatePostUserData(ctx, p); err != nil {
return err
}
// userId check
account, err := s.domain.SearchByUserId(ctx, p.Id)
if check(err) != nil {
return err
}
if account.Id != "" && account.Id == p.Id {
return errors.BadRequest("users-userId-check", "account already exists")
}
// email check
account, err = s.domain.SearchByEmail(ctx, p.Email)
if check(err) != nil {
return err
}
if account.Id != "" && account.Email == p.Email {
return errors.BadRequest("users-email-check", "email already exists")
}
// username check
account, err = s.domain.SearchByUsername(ctx, p.Username)
if check(err) != nil {
return err
}
if account.Id != "" && account.Username == p.Username {
return errors.BadRequest("users-username-check", "username already exists")
}
salt := random(16)
h, err := bcrypt.GenerateFromPassword([]byte(x+salt+req.Password), 10)
if err != nil {
return errors.InternalServerError("users-Create", err.Error())
}
pp := base64.StdEncoding.EncodeToString(h)
acc := &pb.Account{
Id: p.Id,
Username: p.Username,
Email: p.Email,
Profile: req.Profile,
}
err = s.domain.Create(ctx, acc, salt, pp)
if err != nil {
return err
}
// return the account
rsp.Account = acc
return nil
}
func (s *User) Read(ctx context.Context, req *pb.ReadRequest, rsp *pb.ReadResponse) error {
var account = &pb.Account{}
var err error
switch {
case req.Id != "":
account, err = s.domain.Read(ctx, req.Id)
case req.Username != "":
account, err = s.domain.SearchByUsername(ctx, req.Username)
case req.Email != "":
account, err = s.domain.SearchByEmail(ctx, req.Email)
}
rsp.Account = account
if err != nil {
return err
}
return nil
}
func (s *User) Update(ctx context.Context, req *pb.UpdateRequest, rsp *pb.UpdateResponse) error {
// based on the docs, Update allows user to update email or username.
// here we handle three cases, when Id and Email are provided,
// when Id and Username are provided and lastly, when Id, Email and Username are provided.
check := func(err error) error {
if err != nil && err.Error() != domain.ErrNotFound.Error() {
return err
}
return nil
}
// fetch account
account, err := s.domain.SearchByUserId(ctx, req.Id)
if err != nil {
return err
}
// check if req.Email is empty and replace it with account.Email
// in case of absence, this is neccessary step to prevent validatePostUserData
// form throwing an error
if req.Email == "" {
req.Email = account.Email
p := &userPayload{Id: req.Id, Email: req.Email, Username: req.Username}
if err := s.validatePostUserData(ctx, p); err != nil {
return err
}
// check if the new Username is already exists in thge store
account, err = s.domain.SearchByUsername(ctx, p.Username)
if check(err) != nil {
return err
}
if account.Id != "" && account.Username == p.Username {
return errors.BadRequest("users-username-check", "username already exists")
}
return s.domain.Update(ctx, &pb.Account{
Id: p.Id,
Username: p.Username,
Email: p.Email,
Profile: req.Profile,
})
}
// check if req.Username is empty, same as above
if req.Username == "" {
req.Username = account.Username
p := &userPayload{Id: req.Id, Email: req.Email, Username: req.Username}
if err := s.validatePostUserData(ctx, p); err != nil {
return err
}
// check if the new Email is already exists in the store
account, err = s.domain.SearchByEmail(ctx, p.Email)
if check(err) != nil {
return err
}
if account.Id != "" && account.Email == p.Email {
return errors.BadRequest("users-email-check", "email already exists")
}
return s.domain.Update(ctx, &pb.Account{
Id: p.Id,
Username: p.Username,
Email: p.Email,
Profile: req.Profile,
})
}
// if both new Email and new Username were provided
p := &userPayload{Id: req.Id, Email: req.Email, Username: req.Username}
if err := s.validatePostUserData(ctx, p); err != nil {
return err
}
// check if the new Email is already exists in the store
account, err = s.domain.SearchByEmail(ctx, p.Email)
if check(err) != nil {
return err
}
if account.Id != "" && account.Email == p.Email {
return errors.BadRequest("users-email-check", "email already exists")
}
// check if the new Username is already exists in thge store
account, err = s.domain.SearchByUsername(ctx, p.Username)
if check(err) != nil {
return err
}
if account.Id != "" && account.Username == p.Username {
return errors.BadRequest("users-username-check", "username already exists")
}
return s.domain.Update(ctx, &pb.Account{
Id: p.Id,
Username: p.Username,
Email: p.Email,
Profile: req.Profile,
})
}
func (s *User) Delete(ctx context.Context, req *pb.DeleteRequest, rsp *pb.DeleteResponse) error {
return s.domain.Delete(ctx, req.Id)
}
func (s *User) UpdatePassword(ctx context.Context, req *pb.UpdatePasswordRequest, rsp *pb.UpdatePasswordResponse) error {
usr, err := s.domain.Read(ctx, req.UserId)
if err != nil {
return errors.InternalServerError("user.updatepassword", err.Error())
}
if req.NewPassword != req.ConfirmPassword {
return errors.InternalServerError("user.updatepassword", "Passwords don't match")
}
salt, hashed, err := s.domain.SaltAndPassword(ctx, usr.Id)
if err != nil {
return errors.InternalServerError("user.updatepassword", err.Error())
}
hh, err := base64.StdEncoding.DecodeString(hashed)
if err != nil {
return errors.InternalServerError("user.updatepassword", err.Error())
}
if err := bcrypt.CompareHashAndPassword(hh, []byte(x+salt+req.OldPassword)); err != nil {
return errors.Unauthorized("user.updatepassword", err.Error())
}
salt = random(16)
h, err := bcrypt.GenerateFromPassword([]byte(x+salt+req.NewPassword), 10)
if err != nil {
return errors.InternalServerError("user.updatepassword", err.Error())
}
pp := base64.StdEncoding.EncodeToString(h)
if err := s.domain.UpdatePassword(ctx, req.UserId, salt, pp); err != nil {
return errors.InternalServerError("user.updatepassword", err.Error())
}
return nil
}
func (s *User) Login(ctx context.Context, req *pb.LoginRequest, rsp *pb.LoginResponse) error {
username := strings.ToLower(req.Username)
email := strings.ToLower(req.Email)
accounts, err := s.domain.Search(ctx, username, email)
if err != nil {
return err
}
if len(accounts) == 0 {
return fmt.Errorf("account not found")
}
salt, hashed, err := s.domain.SaltAndPassword(ctx, accounts[0].Id)
if err != nil {
return err
}
hh, err := base64.StdEncoding.DecodeString(hashed)
if err != nil {
return errors.InternalServerError("user.Login", err.Error())
}
if err := bcrypt.CompareHashAndPassword(hh, []byte(x+salt+req.Password)); err != nil {
return errors.Unauthorized("user.login", err.Error())
}
// save session
sess := &pb.Session{
Id: random(128),
Created: time.Now().Unix(),
Expires: time.Now().Add(time.Hour * 24 * 7).Unix(),
UserId: accounts[0].Id,
}
if err := s.domain.CreateSession(ctx, sess); err != nil {
return errors.InternalServerError("user.Login", err.Error())
}
rsp.Session = sess
return nil
}
func (s *User) Logout(ctx context.Context, req *pb.LogoutRequest, rsp *pb.LogoutResponse) error {
return s.domain.DeleteSession(ctx, req.SessionId)
}
func (s *User) ReadSession(ctx context.Context, req *pb.ReadSessionRequest, rsp *pb.ReadSessionResponse) error {
sess, err := s.domain.ReadSession(ctx, req.SessionId)
if err != nil {
return err
}
rsp.Session = sess
return nil
}
func (s *User) VerifyEmail(ctx context.Context, req *pb.VerifyEmailRequest, rsp *pb.VerifyEmailResponse) error {
if len(req.Token) == 0 {
return errors.BadRequest("user.verifytoken", "missing token")
}
// check the token exists
tenant, email, err := s.domain.ReadToken(ctx, req.Token)
if err != nil {
logger.Error("Failed to read token: %v", err)
return err
}
// update the user
err = s.domain.MarkVerified(ctx, tenant, email)
if err != nil {
logger.Error("Failed to mark email: %s for tenant: %s as verified: %v", email, tenant, err)
}
return err
}
func (s *User) SendVerificationEmail(ctx context.Context, req *pb.SendVerificationEmailRequest, rsp *pb.SendVerificationEmailResponse) error {
if len(req.Email) == 0 {
return errors.BadRequest("user.sendverificationemail", "missing email")
}
// search for the user
account, err := s.domain.SearchByEmail(ctx, req.Email)
if err != nil {
return err
}
// generate random token
token := random(256)
// generate/save a token for verification
err = s.domain.CreateToken(ctx, req.Email, token)
if err != nil {
return err
}
return s.domain.SendEmail(req.FromName, req.Email, account.Username, req.Subject, req.TextContent, token, req.RedirectUrl, req.FailureRedirectUrl)
}
func (s *User) SendPasswordResetEmail(ctx context.Context, req *pb.SendPasswordResetEmailRequest, rsp *pb.SendPasswordResetEmailResponse) error {
if len(req.Email) == 0 {
return errors.BadRequest("user.sendpasswordresetemail", "missing email")
}
if len(req.Subject) == 0 {
return errors.BadRequest("user.sendpasswordresetemail", "missing subject")
}
if len(req.TextContent) == 0 {
return errors.BadRequest("user.sendpasswordresetemail", "missing textContent")
}
// look for an existing user
account, err := s.domain.SearchByEmail(ctx, req.Email)
if err != nil {
return err
}
var expiry int64 = 1800 // 1800 secs = 30 min
if req.Expiration > 0 {
expiry = req.Expiration
}
if err != nil {
return err
}
code := random(8)
// save the password reset code
_, err = s.domain.SavePasswordResetCode(ctx, account.Id, code, time.Duration(expiry)*time.Second)
if err != nil {
return err
}
// save the code in the database and then send via email
return s.domain.SendPasswordResetEmail(ctx, account.Id, code, req.FromName, req.Email, account.Username, req.Subject, req.TextContent)
}
func (s *User) ResetPassword(ctx context.Context, req *pb.ResetPasswordRequest, rsp *pb.ResetPasswordResponse) error {
if len(req.Email) == 0 {
return errors.BadRequest("user.resetpassword", "missing email")
}
if len(req.Code) == 0 {
return errors.BadRequest("user.resetpassword", "missing code")
}
if len(req.ConfirmPassword) == 0 {
return errors.BadRequest("user.resetpassword", "missing confirm password")
}
if len(req.NewPassword) == 0 {
return errors.BadRequest("user.resetpassword", "missing new password")
}
if req.ConfirmPassword != req.NewPassword {
return errors.BadRequest("user.resetpassword", "passwords do not match")
}
// look for an existing user
account, err := s.domain.SearchByEmail(ctx, req.Email)
if err != nil {
return err
}
// validate the code
_, err = s.domain.ReadPasswordResetCode(ctx, account.Id, req.Code)
if err != nil {
return err
}
// no error means it exists and not expired
salt := random(16)
h, err := bcrypt.GenerateFromPassword([]byte(x+salt+req.NewPassword), 10)
if err != nil {
return errors.InternalServerError("user.ResetPassword", err.Error())
}
pp := base64.StdEncoding.EncodeToString(h)
// update the user password
if err := s.domain.UpdatePassword(ctx, account.Id, salt, pp); err != nil {
return errors.InternalServerError("user.resetpassword", err.Error())
}
// delete our saved code
s.domain.DeletePasswordResetCode(ctx, account.Id, req.Code)
return nil
}
func (s *User) List(ctx context.Context, request *pb.ListRequest, response *pb.ListResponse) error {
accs, err := s.domain.List(ctx, request.Offset, request.Limit)
if err != nil && err != domain.ErrNotFound {
return errors.InternalServerError("user.List", "Error retrieving user list")
}
response.Users = make([]*pb.Account, len(accs))
for i, v := range accs {
response.Users[i] = v
}
return nil
}
func (s *User) SendMagicLink(ctx context.Context, req *pb.SendMagicLinkRequest, rsp *pb.SendMagicLinkResponse) error {
// check if the email has the correct format
if !emailFormat.MatchString(req.Email) {
return errors.BadRequest("SendMagicLink.email-format-check", "email has wrong format")
}
// check if the email exist in the DB
account, err := s.domain.SearchByEmail(ctx, req.Email)
if err != nil && err.Error() == "not found" {
return errors.BadRequest("SendMagicLink.email-check", "email doesn't exist")
} else if err != nil {
return errors.BadRequest("SendMagicLink.email-check", err.Error())
}
// create a token object
token := random(128)
// set ttl to 60 seconds
ttl := 60
// save token, so we can retrieve it later
err = s.domain.CacheToken(ctx, token, req.Email, ttl)
if err != nil {
logger.Errorf("SendMagicLink.cacheToken error: %v", err)
return errors.BadRequest("SendMagicLink.cacheToken", "Oooops something went wrong")
}
// send magic link to email address
err = s.domain.SendMLE(req.FromName, req.Email, account.Username, req.Subject, req.TextContent, token, req.Address, req.Endpoint)
if err != nil {
logger.Errorf("SendMagicLink.cacheToken error: %v", err)
return errors.BadRequest("SendMagicLink.sendEmail", "Oooops something went wrong")
}
return nil
}
func (s *User) VerifyToken(ctx context.Context, req *pb.VerifyTokenRequest, rsp *pb.VerifyTokenResponse) error {
// extract token
token := req.Token
// check if token is valid
email, err := s.domain.CacheReadToken(ctx, token)
if err != nil && err.Error() == "token not found" {
rsp.IsValid = false
rsp.Message = err.Error()
return nil
} else if err != nil && err.Error() == "token expired" {
rsp.IsValid = false
rsp.Message = err.Error()
return nil
} else if err != nil && err.Error() == "token empty" {
rsp.IsValid = false
rsp.Message = err.Error()
return nil
} else if err != nil {
rsp.IsValid = false
rsp.Message = err.Error()
return errors.BadRequest("VerifyToken.CacheReadToken", err.Error())
}
// save session
account, err := s.domain.SearchByEmail(ctx, email)
if err != nil {
rsp.IsValid = false
rsp.Message = "account not found"
return err
}
sess := &pb.Session{
Id: random(128),
Created: time.Now().Unix(),
Expires: time.Now().Add(time.Hour * 24 * 7).Unix(),
UserId: account.Id,
}
if err := s.domain.CreateSession(ctx, sess); err != nil {
rsp.IsValid = false
rsp.Message = "Creation of a new session has failed"
return errors.InternalServerError("VerifyToken.createSession", err.Error())
}
rsp.IsValid = true
rsp.Session = sess
return nil
}
func (s *User) DeleteData(ctx context.Context, request *adminpb.DeleteDataRequest, response *adminpb.DeleteDataResponse) error {
if _, err := pauth.VerifyMicroAdmin(ctx, "user.DeleteData"); err != nil {
return err
}
if len(request.TenantId) < 10 { // deliberate length check so we don't delete all the things
return errors.BadRequest("user.DeleteData", "Missing tenant ID")
}
return s.domain.DeleteTenantData(request.TenantId)
}