feat: add OTP verification service and integrate email-based token authentication for account activation and password reset endpoints
This commit is contained in:
parent
8dc4f61d74
commit
aa2694020d
@ -67,17 +67,117 @@ class AppNotificationController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'status': 'error', 'message': str(e)}
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
|
||||||
|
@http.route('/api/loyalty/send_otp', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
|
||||||
|
def send_otp(self, **kw):
|
||||||
|
"""
|
||||||
|
Public endpoint to send a verification OTP to an email address.
|
||||||
|
"""
|
||||||
|
email = kw.get('email')
|
||||||
|
phone = kw.get('phone')
|
||||||
|
phone_or_email = kw.get('phone_or_email')
|
||||||
|
otp_type = kw.get('type')
|
||||||
|
|
||||||
|
if not otp_type:
|
||||||
|
return {'status': 'error', 'message': 'OTP type is required.'}
|
||||||
|
|
||||||
|
if otp_type == 'signup':
|
||||||
|
if not email or not phone:
|
||||||
|
return {'status': 'error', 'message': 'Email and Phone are required for sign-up.'}
|
||||||
|
# Check if partner or user already exists
|
||||||
|
candidates = normalize_phone_search(phone)
|
||||||
|
existing_partner = request.env['res.partner'].sudo().search([
|
||||||
|
'|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates)
|
||||||
|
], limit=1)
|
||||||
|
if existing_partner:
|
||||||
|
return {'status': 'error', 'message': 'A member with this phone number is already registered.'}
|
||||||
|
|
||||||
|
existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([
|
||||||
|
'|', ('login', '=', phone), ('login', 'in', candidates), ('email', '=', email)
|
||||||
|
], limit=1)
|
||||||
|
if existing_user:
|
||||||
|
return {'status': 'error', 'message': 'A user with this phone number or email already exists.'}
|
||||||
|
|
||||||
|
elif otp_type == 'activation':
|
||||||
|
if not email or not phone:
|
||||||
|
return {'status': 'error', 'message': 'Email and Phone are required for activation.'}
|
||||||
|
# Check if partner exists
|
||||||
|
candidates = normalize_phone_search(phone)
|
||||||
|
partner = request.env['res.partner'].sudo().search([
|
||||||
|
'|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates)
|
||||||
|
], limit=1)
|
||||||
|
if not partner:
|
||||||
|
return {'status': 'error', 'message': 'Member with this phone number not found. Please contact support or register.'}
|
||||||
|
|
||||||
|
# Check if user already active
|
||||||
|
user = request.env['res.users'].sudo().with_context(active_test=False).search([('partner_id', '=', partner.id)], limit=1)
|
||||||
|
if user and user.active:
|
||||||
|
return {'status': 'error', 'message': 'Account is already active. Please login or reset password.'}
|
||||||
|
|
||||||
|
# Check if email is already taken by another active user
|
||||||
|
existing_email_user = request.env['res.users'].sudo().search([('email', '=', email), ('partner_id', '!=', partner.id)], limit=1)
|
||||||
|
if existing_email_user:
|
||||||
|
return {'status': 'error', 'message': 'This email address is already registered to another account.'}
|
||||||
|
|
||||||
|
elif otp_type == 'reset_password':
|
||||||
|
if not phone_or_email:
|
||||||
|
return {'status': 'error', 'message': 'Phone number or Email is required.'}
|
||||||
|
|
||||||
|
# Find user
|
||||||
|
user = None
|
||||||
|
if '@' in phone_or_email:
|
||||||
|
user = request.env['res.users'].sudo().search([('email', '=', phone_or_email)], limit=1)
|
||||||
|
else:
|
||||||
|
candidates = normalize_phone_search(phone_or_email)
|
||||||
|
user = request.env['res.users'].sudo().search([('login', 'in', candidates)], limit=1)
|
||||||
|
if not user:
|
||||||
|
partner = request.env['res.partner'].sudo().search([
|
||||||
|
'|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates)
|
||||||
|
], limit=1)
|
||||||
|
if partner:
|
||||||
|
user = request.env['res.users'].sudo().search([('partner_id', '=', partner.id)], limit=1)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return {'status': 'error', 'message': 'User account not found.'}
|
||||||
|
|
||||||
|
email = user.email
|
||||||
|
if not email or '@miemapan.com' in email or '@' not in email:
|
||||||
|
return {'status': 'error', 'message': 'Your account does not have a valid email set. Please contact support.'}
|
||||||
|
|
||||||
|
phone = user.login
|
||||||
|
|
||||||
|
try:
|
||||||
|
request.env['loyalty.verification.otp'].sudo().generate_otp(email=email, phone=phone, otp_type=otp_type)
|
||||||
|
# Mask email for privacy response
|
||||||
|
parts = email.split('@')
|
||||||
|
masked_email = parts[0][:2] + '*' * (len(parts[0]) - 2) + '@' + parts[1] if len(parts[0]) > 2 else email
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Verification code sent to {masked_email}.',
|
||||||
|
'email': email
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'message': f'Failed to send verification code: {str(e)}'}
|
||||||
|
|
||||||
@http.route('/api/loyalty/activate_account', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
|
@http.route('/api/loyalty/activate_account', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
|
||||||
def activate_account(self, **kw):
|
def activate_account(self, **kw):
|
||||||
"""
|
"""
|
||||||
Public endpoint to activate/set password for an existing partner.
|
Public endpoint to activate/set password for an existing partner.
|
||||||
"""
|
"""
|
||||||
phone = kw.get('phone')
|
phone = kw.get('phone')
|
||||||
|
email = kw.get('email')
|
||||||
password = kw.get('password')
|
password = kw.get('password')
|
||||||
birth_date = kw.get('birth_date')
|
birth_date = kw.get('birth_date')
|
||||||
|
otp = kw.get('otp')
|
||||||
|
|
||||||
if not phone or not password:
|
if not phone or not password or not email or not otp:
|
||||||
return {'status': 'error', 'message': 'Phone and Password are required.'}
|
return {'status': 'error', 'message': 'Phone, Email, Password, and Verification Code (OTP) are required.'}
|
||||||
|
|
||||||
|
# Verify OTP first
|
||||||
|
otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp(
|
||||||
|
otp_code=otp, email=email, phone=phone, otp_type='activation'
|
||||||
|
)
|
||||||
|
if not otp_verified:
|
||||||
|
return {'status': 'error', 'message': 'Invalid or expired verification code.'}
|
||||||
|
|
||||||
candidates = normalize_phone_search(phone)
|
candidates = normalize_phone_search(phone)
|
||||||
partner = request.env['res.partner'].sudo().search([
|
partner = request.env['res.partner'].sudo().search([
|
||||||
@ -94,12 +194,17 @@ class AppNotificationController(http.Controller):
|
|||||||
if str(partner.birth_date) != str(birth_date):
|
if str(partner.birth_date) != str(birth_date):
|
||||||
return {'status': 'error', 'message': 'Birth Date does not match our records.'}
|
return {'status': 'error', 'message': 'Birth Date does not match our records.'}
|
||||||
|
|
||||||
|
# Update email on partner
|
||||||
|
partner.sudo().write({'email': email})
|
||||||
|
|
||||||
# Find or create user
|
# Find or create user
|
||||||
user = request.env['res.users'].sudo().with_context(active_test=False).search([('partner_id', '=', partner.id)], limit=1)
|
user = request.env['res.users'].sudo().with_context(active_test=False).search([('partner_id', '=', partner.id)], limit=1)
|
||||||
if user:
|
if user:
|
||||||
user.sudo().write({
|
user.sudo().write({
|
||||||
'company_id': request.env.company.id,
|
'company_id': request.env.company.id,
|
||||||
'company_ids': [(6, 0, [request.env.company.id])],
|
'company_ids': [(6, 0, [request.env.company.id])],
|
||||||
|
'login': phone,
|
||||||
|
'email': email,
|
||||||
'password': password,
|
'password': password,
|
||||||
'active': True
|
'active': True
|
||||||
})
|
})
|
||||||
@ -116,7 +221,7 @@ class AppNotificationController(http.Controller):
|
|||||||
user = portal_template.sudo().with_context(no_reset_password=True).copy({
|
user = portal_template.sudo().with_context(no_reset_password=True).copy({
|
||||||
'name': partner.name,
|
'name': partner.name,
|
||||||
'login': phone,
|
'login': phone,
|
||||||
'email': partner.email or f"{phone}@miemapan.com",
|
'email': email,
|
||||||
'partner_id': partner.id,
|
'partner_id': partner.id,
|
||||||
'active': False,
|
'active': False,
|
||||||
})
|
})
|
||||||
@ -143,12 +248,21 @@ class AppNotificationController(http.Controller):
|
|||||||
"""
|
"""
|
||||||
name = kw.get('name')
|
name = kw.get('name')
|
||||||
phone = kw.get('phone')
|
phone = kw.get('phone')
|
||||||
|
email = kw.get('email')
|
||||||
birth_date = kw.get('birth_date')
|
birth_date = kw.get('birth_date')
|
||||||
gender = kw.get('gender')
|
gender = kw.get('gender')
|
||||||
password = kw.get('password')
|
password = kw.get('password')
|
||||||
|
otp = kw.get('otp')
|
||||||
|
|
||||||
if not name or not phone or not password:
|
if not name or not phone or not email or not password or not otp:
|
||||||
return {'status': 'error', 'message': 'Name, Phone, and Password are required.'}
|
return {'status': 'error', 'message': 'Name, Phone, Email, Password, and Verification Code (OTP) are required.'}
|
||||||
|
|
||||||
|
# Verify OTP first
|
||||||
|
otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp(
|
||||||
|
otp_code=otp, email=email, phone=phone, otp_type='signup'
|
||||||
|
)
|
||||||
|
if not otp_verified:
|
||||||
|
return {'status': 'error', 'message': 'Invalid or expired verification code.'}
|
||||||
|
|
||||||
candidates = normalize_phone_search(phone)
|
candidates = normalize_phone_search(phone)
|
||||||
|
|
||||||
@ -159,15 +273,16 @@ class AppNotificationController(http.Controller):
|
|||||||
return {'status': 'error', 'message': 'A member with this phone number is already registered.'}
|
return {'status': 'error', 'message': 'A member with this phone number is already registered.'}
|
||||||
|
|
||||||
existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([
|
existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([
|
||||||
'|', ('login', '=', phone), ('login', 'in', candidates)
|
'|', ('login', '=', phone), ('login', 'in', candidates), ('email', '=', email)
|
||||||
], limit=1)
|
], limit=1)
|
||||||
if existing_user:
|
if existing_user:
|
||||||
return {'status': 'error', 'message': 'A user with this phone number login already exists.'}
|
return {'status': 'error', 'message': 'A user with this phone number or email already exists.'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
partner_vals = {
|
partner_vals = {
|
||||||
'name': name,
|
'name': name,
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
|
'email': email,
|
||||||
'birth_date': birth_date,
|
'birth_date': birth_date,
|
||||||
'gender': gender,
|
'gender': gender,
|
||||||
'company_id': request.env.company.id,
|
'company_id': request.env.company.id,
|
||||||
@ -178,7 +293,7 @@ class AppNotificationController(http.Controller):
|
|||||||
user = portal_template.sudo().with_context(no_reset_password=True).copy({
|
user = portal_template.sudo().with_context(no_reset_password=True).copy({
|
||||||
'name': partner.name,
|
'name': partner.name,
|
||||||
'login': phone,
|
'login': phone,
|
||||||
'email': f"{phone}@miemapan.com",
|
'email': email,
|
||||||
'partner_id': partner.id,
|
'partner_id': partner.id,
|
||||||
'active': False,
|
'active': False,
|
||||||
})
|
})
|
||||||
@ -188,7 +303,6 @@ class AppNotificationController(http.Controller):
|
|||||||
'company_ids': [(6, 0, [request.env.company.id])],
|
'company_ids': [(6, 0, [request.env.company.id])],
|
||||||
'password': password,
|
'password': password,
|
||||||
'active': True,
|
'active': True,
|
||||||
'active': True,
|
|
||||||
})
|
})
|
||||||
# Ensure portal group
|
# Ensure portal group
|
||||||
portal_group = request.env.ref('base.group_portal')
|
portal_group = request.env.ref('base.group_portal')
|
||||||
@ -199,6 +313,51 @@ class AppNotificationController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'status': 'error', 'message': f'Registration failed: {str(e)}'}
|
return {'status': 'error', 'message': f'Registration failed: {str(e)}'}
|
||||||
|
|
||||||
|
@http.route('/api/loyalty/reset_password', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
|
||||||
|
def reset_password(self, **kw):
|
||||||
|
"""
|
||||||
|
Public endpoint to reset user password using verification OTP.
|
||||||
|
"""
|
||||||
|
phone_or_email = kw.get('phone_or_email')
|
||||||
|
otp = kw.get('otp')
|
||||||
|
new_password = kw.get('password')
|
||||||
|
|
||||||
|
if not phone_or_email or not otp or not new_password:
|
||||||
|
return {'status': 'error', 'message': 'Phone/Email, OTP, and New Password are required.'}
|
||||||
|
|
||||||
|
# Find user
|
||||||
|
user = None
|
||||||
|
if '@' in phone_or_email:
|
||||||
|
user = request.env['res.users'].sudo().search([('email', '=', phone_or_email)], limit=1)
|
||||||
|
else:
|
||||||
|
candidates = normalize_phone_search(phone_or_email)
|
||||||
|
user = request.env['res.users'].sudo().search([('login', 'in', candidates)], limit=1)
|
||||||
|
if not user:
|
||||||
|
partner = request.env['res.partner'].sudo().search([
|
||||||
|
'|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates)
|
||||||
|
], limit=1)
|
||||||
|
if partner:
|
||||||
|
user = request.env['res.users'].sudo().search([('partner_id', '=', partner.id)], limit=1)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return {'status': 'error', 'message': 'User account not found.'}
|
||||||
|
|
||||||
|
# Verify OTP
|
||||||
|
otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp(
|
||||||
|
otp_code=otp, email=user.email, phone=user.login, otp_type='reset_password'
|
||||||
|
)
|
||||||
|
if not otp_verified:
|
||||||
|
return {'status': 'error', 'message': 'Invalid or expired verification code.'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
user.sudo().write({
|
||||||
|
'password': new_password,
|
||||||
|
'active': True
|
||||||
|
})
|
||||||
|
return {'status': 'success', 'message': 'Password has been reset successfully.'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'status': 'error', 'message': f'Failed to reset password: {str(e)}'}
|
||||||
|
|
||||||
@http.route('/api/loyalty/delete_account', type='jsonrpc', auth='user', methods=['POST'], csrf=False)
|
@http.route('/api/loyalty/delete_account', type='jsonrpc', auth='user', methods=['POST'], csrf=False)
|
||||||
def delete_account(self, **kw):
|
def delete_account(self, **kw):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -2,4 +2,6 @@ from . import res_partner
|
|||||||
from . import app_notification
|
from . import app_notification
|
||||||
from . import res_users
|
from . import res_users
|
||||||
from . import loyalty_card
|
from . import loyalty_card
|
||||||
|
from . import loyalty_verification_otp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
97
models/loyalty_verification_otp.py
Normal file
97
models/loyalty_verification_otp.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
class LoyaltyVerificationOtp(models.Model):
|
||||||
|
_name = 'loyalty.verification.otp'
|
||||||
|
_description = 'Loyalty Verification OTP'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
email = fields.Char(string='Email')
|
||||||
|
phone = fields.Char(string='Phone')
|
||||||
|
otp_code = fields.Char(string='OTP Code', required=True)
|
||||||
|
expiry_time = fields.Datetime(string='Expiry Time', required=True)
|
||||||
|
otp_type = fields.Selection([
|
||||||
|
('signup', 'Sign-up'),
|
||||||
|
('activation', 'Activation'),
|
||||||
|
('reset_password', 'Reset Password')
|
||||||
|
], string='OTP Type', required=True)
|
||||||
|
is_verified = fields.Boolean(string='Is Verified', default=False)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def generate_otp(self, email=None, phone=None, otp_type=None):
|
||||||
|
if not otp_type:
|
||||||
|
raise ValueError("OTP type is required.")
|
||||||
|
|
||||||
|
# Generate a 6-digit random code
|
||||||
|
otp_code = str(random.randint(100000, 999999))
|
||||||
|
expiry_time = fields.Datetime.now() + timedelta(minutes=15)
|
||||||
|
|
||||||
|
# Create record
|
||||||
|
otp_record = self.sudo().create({
|
||||||
|
'email': email,
|
||||||
|
'phone': phone,
|
||||||
|
'otp_code': otp_code,
|
||||||
|
'expiry_time': expiry_time,
|
||||||
|
'otp_type': otp_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Send Email
|
||||||
|
if email:
|
||||||
|
subject = "Verification Code - Mie Mapan"
|
||||||
|
if otp_type == 'reset_password':
|
||||||
|
subject = "Reset Password Verification Code - Mie Mapan"
|
||||||
|
elif otp_type == 'activation':
|
||||||
|
subject = "Account Activation Verification Code - Mie Mapan"
|
||||||
|
|
||||||
|
body_html = f"""
|
||||||
|
<div style="font-family: Helvetica, Arial, sans-serif; min-width: 1000px; overflow: auto; line-height: 2">
|
||||||
|
<div style="margin: 50px auto; width: 70%; padding: 20px 0">
|
||||||
|
<div style="border-bottom: 1px solid #eee">
|
||||||
|
<a href="" style="font-size: 1.4em; color: #ff5722; text-decoration: none; font-weight: 600">Mie Mapan</a>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 1.1em">Hi,</p>
|
||||||
|
<p>Thank you for choosing Mie Mapan. Use the following OTP to complete your request. This OTP is valid for 15 minutes.</p>
|
||||||
|
<h2 style="background: #ff5722; margin: 0 auto; width: max-content; padding: 0 10px; color: #fff; border-radius: 4px;">{otp_code}</h2>
|
||||||
|
<p style="font-size: 0.9em;">Regards,<br />Mie Mapan Team</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee" />
|
||||||
|
<div style="float: right; padding: 8px 0; color: #aaa; font-size: 0.8em; line-height: 1; font-weight: 300">
|
||||||
|
<p>Mie Mapan Inc</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
mail_values = {
|
||||||
|
'subject': subject,
|
||||||
|
'body_html': body_html,
|
||||||
|
'email_to': email,
|
||||||
|
'email_from': self.env.user.email or 'no-reply@miemapan.com',
|
||||||
|
}
|
||||||
|
# Send using sudo context
|
||||||
|
self.env['mail.mail'].sudo().create(mail_values).send()
|
||||||
|
|
||||||
|
return otp_record
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def verify_otp(self, otp_code, email=None, phone=None, otp_type=None):
|
||||||
|
if not otp_code:
|
||||||
|
return False
|
||||||
|
|
||||||
|
domain = [
|
||||||
|
('otp_code', '=', otp_code),
|
||||||
|
('otp_type', '=', otp_type),
|
||||||
|
('is_verified', '=', False),
|
||||||
|
('expiry_time', '>=', fields.Datetime.now())
|
||||||
|
]
|
||||||
|
if email:
|
||||||
|
domain.append(('email', '=', email))
|
||||||
|
if phone:
|
||||||
|
domain.append(('phone', '=', phone))
|
||||||
|
|
||||||
|
record = self.sudo().search(domain, order='create_date desc', limit=1)
|
||||||
|
if record:
|
||||||
|
record.write({'is_verified': True})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@ -7,3 +7,6 @@ access_loyalty_program_portal,loyalty.program.portal,loyalty.model_loyalty_progr
|
|||||||
access_loyalty_history_portal,loyalty.history.portal,loyalty.model_loyalty_history,base.group_portal,1,0,0,0
|
access_loyalty_history_portal,loyalty.history.portal,loyalty.model_loyalty_history,base.group_portal,1,0,0,0
|
||||||
access_loyalty_reward_portal,loyalty.reward.portal,loyalty.model_loyalty_reward,base.group_portal,1,0,0,0
|
access_loyalty_reward_portal,loyalty.reward.portal,loyalty.model_loyalty_reward,base.group_portal,1,0,0,0
|
||||||
access_loyalty_rule_portal,loyalty.rule.portal,loyalty.model_loyalty_rule,base.group_portal,1,0,0,0
|
access_loyalty_rule_portal,loyalty.rule.portal,loyalty.model_loyalty_rule,base.group_portal,1,0,0,0
|
||||||
|
access_loyalty_verification_otp_admin,loyalty.verification.otp.admin,model_loyalty_verification_otp,base.group_system,1,1,1,1
|
||||||
|
access_loyalty_verification_otp_portal,loyalty.verification.otp.portal,model_loyalty_verification_otp,base.group_portal,1,1,1,1
|
||||||
|
|
||||||
|
|||||||
|
Loading…
Reference in New Issue
Block a user