From ace90da7ed6ee69a509401108344cef26ecb2c42 Mon Sep 17 00:00:00 2001 From: "Jamie.Rees" Date: Fri, 14 Jul 2017 16:23:57 +0100 Subject: [PATCH] #1456 Reset Password stuff #865 --- src/Ombi.DependencyInjection/IocExtensions.cs | 1 + .../Agents/EmailNotification.cs | 44 +----- .../GenericEmailProvider.cs | 54 +++++++ src/Ombi.Notifications/IEmailProvider.cs | 11 ++ src/Ombi/ClientApp/app/app.module.ts | 10 +- src/Ombi/ClientApp/app/interfaces/IUser.ts | 5 + .../ClientApp/app/login/login.component.html | 4 +- .../app/login/resetpassword.component.html | 20 +++ .../app/login/resetpassword.component.ts | 42 ++++++ .../login/tokenresetpassword.component.html | 37 +++++ .../app/login/tokenresetpassword.component.ts | 57 ++++++++ .../app/services/identity.service.ts | 10 +- src/Ombi/Controllers/IdentityController.cs | 136 +++++++++++++++--- .../Models/Identity/ResetPasswordToken.cs | 9 ++ .../Models/Identity/SubmitPasswordReset.cs | 7 + 15 files changed, 383 insertions(+), 64 deletions(-) create mode 100644 src/Ombi.Notifications/GenericEmailProvider.cs create mode 100644 src/Ombi.Notifications/IEmailProvider.cs create mode 100644 src/Ombi/ClientApp/app/login/resetpassword.component.html create mode 100644 src/Ombi/ClientApp/app/login/resetpassword.component.ts create mode 100644 src/Ombi/ClientApp/app/login/tokenresetpassword.component.html create mode 100644 src/Ombi/ClientApp/app/login/tokenresetpassword.component.ts create mode 100644 src/Ombi/Models/Identity/ResetPasswordToken.cs create mode 100644 src/Ombi/Models/Identity/SubmitPasswordReset.cs diff --git a/src/Ombi.DependencyInjection/IocExtensions.cs b/src/Ombi.DependencyInjection/IocExtensions.cs index 81050608f..2e8ce9e0d 100644 --- a/src/Ombi.DependencyInjection/IocExtensions.cs +++ b/src/Ombi.DependencyInjection/IocExtensions.cs @@ -90,6 +90,7 @@ namespace Ombi.DependencyInjection { services.AddTransient(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); diff --git a/src/Ombi.Notifications/Agents/EmailNotification.cs b/src/Ombi.Notifications/Agents/EmailNotification.cs index 0ded02299..441901cbd 100644 --- a/src/Ombi.Notifications/Agents/EmailNotification.cs +++ b/src/Ombi.Notifications/Agents/EmailNotification.cs @@ -16,10 +16,11 @@ namespace Ombi.Notifications.Agents { public class EmailNotification : BaseNotification, IEmailNotification { - public EmailNotification(ISettingsService settings, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t) : base(settings, r, m, t) + public EmailNotification(ISettingsService settings, INotificationTemplatesRepository r, IMovieRequestRepository m, ITvRequestRepository t, IEmailProvider prov) : base(settings, r, m, t) { + EmailProvider = prov; } - + private IEmailProvider EmailProvider { get; } public override string NotificationName => nameof(EmailNotification); protected override bool ValidateConfiguration(EmailNotificationSettings settings) @@ -166,44 +167,7 @@ namespace Ombi.Notifications.Agents protected override async Task Send(NotificationMessage model, EmailNotificationSettings settings) { - try - { - var body = new BodyBuilder - { - HtmlBody = model.Message, - //TextBody = model.Other["PlainTextBody"] - }; - - var message = new MimeMessage - { - Body = body.ToMessageBody(), - Subject = model.Subject - }; - message.From.Add(new MailboxAddress(settings.Sender, settings.Sender)); - message.To.Add(new MailboxAddress(model.To, model.To)); - - using (var client = new SmtpClient()) - { - client.Connect(settings.Host, settings.Port); // Let MailKit figure out the correct SecureSocketOptions. - - // Note: since we don't have an OAuth2 token, disable - // the XOAUTH2 authentication mechanism. - client.AuthenticationMechanisms.Remove("XOAUTH2"); - - if (settings.Authentication) - { - client.Authenticate(settings.Username, settings.Password); - } - //Log.Info("sending message to {0} \r\n from: {1}\r\n Are we authenticated: {2}", message.To, message.From, client.IsAuthenticated); - await client.SendAsync(message); - await client.DisconnectAsync(true); - } - } - catch (Exception e) - { - //Log.Error(e); - throw new InvalidOperationException(e.Message); - } + await EmailProvider.Send(model, settings); } protected override async Task Test(NotificationOptions model, EmailNotificationSettings settings) diff --git a/src/Ombi.Notifications/GenericEmailProvider.cs b/src/Ombi.Notifications/GenericEmailProvider.cs new file mode 100644 index 000000000..419c7abd2 --- /dev/null +++ b/src/Ombi.Notifications/GenericEmailProvider.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using MailKit.Net.Smtp; +using MimeKit; +using Ombi.Notifications.Models; +using Ombi.Settings.Settings.Models.Notifications; + +namespace Ombi.Notifications +{ + public class GenericEmailProvider : IEmailProvider + { + public async Task Send(NotificationMessage model, EmailNotificationSettings settings) + { + try + { + var body = new BodyBuilder + { + HtmlBody = model.Message, + //TextBody = model.Other["PlainTextBody"] + }; + + var message = new MimeMessage + { + Body = body.ToMessageBody(), + Subject = model.Subject + }; + message.From.Add(new MailboxAddress(settings.Sender, settings.Sender)); + message.To.Add(new MailboxAddress(model.To, model.To)); + + using (var client = new SmtpClient()) + { + client.Connect(settings.Host, settings.Port); // Let MailKit figure out the correct SecureSocketOptions. + + // Note: since we don't have an OAuth2 token, disable + // the XOAUTH2 authentication mechanism. + client.AuthenticationMechanisms.Remove("XOAUTH2"); + + if (settings.Authentication) + { + client.Authenticate(settings.Username, settings.Password); + } + //Log.Info("sending message to {0} \r\n from: {1}\r\n Are we authenticated: {2}", message.To, message.From, client.IsAuthenticated); + await client.SendAsync(message); + await client.DisconnectAsync(true); + } + } + catch (Exception e) + { + //Log.Error(e); + throw new InvalidOperationException(e.Message); + } + } + } +} \ No newline at end of file diff --git a/src/Ombi.Notifications/IEmailProvider.cs b/src/Ombi.Notifications/IEmailProvider.cs new file mode 100644 index 000000000..f1bb6a358 --- /dev/null +++ b/src/Ombi.Notifications/IEmailProvider.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Ombi.Notifications.Models; +using Ombi.Settings.Settings.Models.Notifications; + +namespace Ombi.Notifications +{ + public interface IEmailProvider + { + Task Send(NotificationMessage model, EmailNotificationSettings settings); + } +} \ No newline at end of file diff --git a/src/Ombi/ClientApp/app/app.module.ts b/src/Ombi/ClientApp/app/app.module.ts index 64353b859..402ebf4e6 100644 --- a/src/Ombi/ClientApp/app/app.module.ts +++ b/src/Ombi/ClientApp/app/app.module.ts @@ -7,7 +7,7 @@ import { RouterModule, Routes } from '@angular/router'; import { HttpModule } from '@angular/http'; // Third Party -import { ButtonModule, DialogModule } from 'primeng/primeng'; +import { ButtonModule, DialogModule, CaptchaModule } from 'primeng/primeng'; import { GrowlModule } from 'primeng/components/growl/growl'; import { DataTableModule, SharedModule } from 'primeng/primeng'; //import { DragulaModule, DragulaService } from 'ng2-dragula/ng2-dragula'; @@ -17,6 +17,8 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { AppComponent } from './app.component'; import { LoginComponent } from './login/login.component'; +import { ResetPasswordComponent } from './login/resetpassword.component'; +import { TokenResetPasswordComponent } from './login/tokenresetpassword.component'; import { LandingPageComponent } from './landingpage/landingpage.component'; import { PageNotFoundComponent } from './errors/not-found.component'; @@ -44,6 +46,7 @@ const routes: Routes = [ //{ path: 'requests-grid', component: RequestGridComponent }, { path: 'login', component: LoginComponent }, + { path: 'reset', component: ResetPasswordComponent }, { path: 'landingpage', component: LandingPageComponent } ]; @@ -71,13 +74,16 @@ const routes: Routes = [ MdTabsModule, ReactiveFormsModule, UserManagementModule, - RequestsModule + RequestsModule, + CaptchaModule ], declarations: [ AppComponent, PageNotFoundComponent, LoginComponent, LandingPageComponent, + ResetPasswordComponent, + TokenResetPasswordComponent ], providers: [ RequestService, diff --git a/src/Ombi/ClientApp/app/interfaces/IUser.ts b/src/Ombi/ClientApp/app/interfaces/IUser.ts index 0f8ae3576..49bac3cc0 100644 --- a/src/Ombi/ClientApp/app/interfaces/IUser.ts +++ b/src/Ombi/ClientApp/app/interfaces/IUser.ts @@ -30,3 +30,8 @@ export interface IUpdateLocalUser extends IUser { confirmNewPassword: string } +export interface IResetPasswordToken{ + email:string, + token:string, + password:string +} \ No newline at end of file diff --git a/src/Ombi/ClientApp/app/login/login.component.html b/src/Ombi/ClientApp/app/login/login.component.html index 2104443a0..ee96038d8 100644 --- a/src/Ombi/ClientApp/app/login/login.component.html +++ b/src/Ombi/ClientApp/app/login/login.component.html @@ -17,8 +17,8 @@ include the remember me checkbox - - Forgot the password? + + Reset your password? diff --git a/src/Ombi/ClientApp/app/login/resetpassword.component.html b/src/Ombi/ClientApp/app/login/resetpassword.component.html new file mode 100644 index 000000000..cd109ddff --- /dev/null +++ b/src/Ombi/ClientApp/app/login/resetpassword.component.html @@ -0,0 +1,20 @@ + +
+
+
+ +
+
+

+ + +
+
+
\ No newline at end of file diff --git a/src/Ombi/ClientApp/app/login/resetpassword.component.ts b/src/Ombi/ClientApp/app/login/resetpassword.component.ts new file mode 100644 index 000000000..5ef90f61f --- /dev/null +++ b/src/Ombi/ClientApp/app/login/resetpassword.component.ts @@ -0,0 +1,42 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, Validators, FormBuilder } from '@angular/forms'; + +import { IdentityService } from '../services/identity.service'; +import { NotificationService } from '../services/notification.service'; +import { SettingsService } from '../services/settings.service'; +import { ICustomizationSettings } from '../interfaces/ISettings'; + +@Component({ + templateUrl: './resetpassword.component.html', + styleUrls: ['./login.component.scss'] +}) +export class ResetPasswordComponent implements OnInit { + + + constructor(private identityService: IdentityService, private notify: NotificationService, + private fb: FormBuilder, private settingsService: SettingsService) { + this.form = this.fb.group({ + email: ["", [Validators.required]], + }); + } + + form: FormGroup; + customizationSettings: ICustomizationSettings; + + ngOnInit(): void { + this.settingsService.getCustomization().subscribe(x => this.customizationSettings = x); + } + + + onSubmit(form: FormGroup): void { + if (form.invalid) { + this.notify.error("Validation", "Email address is required"); + return + } + this.identityService.submitResetPassword(form.value.email).subscribe(x => { + x.errors.forEach((val) => { + this.notify.success("Password Reset", val); + }); + }); + } +} \ No newline at end of file diff --git a/src/Ombi/ClientApp/app/login/tokenresetpassword.component.html b/src/Ombi/ClientApp/app/login/tokenresetpassword.component.html new file mode 100644 index 000000000..3f5fe6819 --- /dev/null +++ b/src/Ombi/ClientApp/app/login/tokenresetpassword.component.html @@ -0,0 +1,37 @@ + +
+
+
+ +
+
+

+ + +
The passwords do not match
+
+ +
The Password is required
+
The Email is required
+
The Confirm Password is required
+
+ + + + + Forgot the password? + +
+ +
+ +
\ No newline at end of file diff --git a/src/Ombi/ClientApp/app/login/tokenresetpassword.component.ts b/src/Ombi/ClientApp/app/login/tokenresetpassword.component.ts new file mode 100644 index 000000000..6d68ff0f2 --- /dev/null +++ b/src/Ombi/ClientApp/app/login/tokenresetpassword.component.ts @@ -0,0 +1,57 @@ +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { FormGroup, Validators, FormBuilder } from '@angular/forms'; + +import { IdentityService } from '../services/identity.service'; +import { NotificationService } from '../services/notification.service'; +import { SettingsService } from '../services/settings.service'; +import { ICustomizationSettings } from '../interfaces/ISettings'; + +@Component({ + templateUrl: './tokenresetpassword.component.html', + styleUrls: ['./login.component.scss'] +}) +export class TokenResetPasswordComponent implements OnInit { + constructor(private identityService: IdentityService, private router: Router, private route: ActivatedRoute, private notify: NotificationService, + private fb: FormBuilder, private settingsService: SettingsService) { + + this.route.params + .subscribe(params => { + this.form = this.fb.group({ + email: ["", [Validators.required]], + password: ["", [Validators.required]], + confirmPassword: ["", [Validators.required]], + token: [params['token']] + }); + }); + } + + form: FormGroup; + customizationSettings: ICustomizationSettings; + + + ngOnInit(): void { + this.settingsService.getCustomization().subscribe(x => this.customizationSettings = x); + } + + + onSubmit(form: FormGroup): void { + if (form.invalid) { + this.notify.error("Validation", "Email address is required"); + return + } + + this.identityService.resetPassword(form.value).subscribe(x => { + if (x.successful) { + this.notify.success("Success", `Your Password has been reset`) + this.router.navigate(['login']); + } else { + x.errors.forEach((val) => { + this.notify.error("Error", val); + }); + } + }); + + } +} \ No newline at end of file diff --git a/src/Ombi/ClientApp/app/services/identity.service.ts b/src/Ombi/ClientApp/app/services/identity.service.ts index 4c2752076..534add969 100644 --- a/src/Ombi/ClientApp/app/services/identity.service.ts +++ b/src/Ombi/ClientApp/app/services/identity.service.ts @@ -4,7 +4,7 @@ import { Http } from '@angular/http'; import { Observable } from 'rxjs/Rx'; import { ServiceAuthHelpers } from './service.helpers'; -import { IUser, IUpdateLocalUser, ICheckbox, IIdentityResult } from '../interfaces/IUser'; +import { IUser, IUpdateLocalUser, ICheckbox, IIdentityResult, IResetPasswordToken } from '../interfaces/IUser'; @Injectable() @@ -47,6 +47,14 @@ export class IdentityService extends ServiceAuthHelpers { return this.http.delete(`${this.url}/${user.id}`, { headers: this.headers }).map(this.extractData); } + submitResetPassword(email:string): Observable{ + return this.regularHttp.post(this.url + 'reset', JSON.stringify({email:email}), { headers: this.headers }).map(this.extractData); + } + + resetPassword(token: IResetPasswordToken):Observable{ + return this.regularHttp.post(this.url + 'resetpassword', JSON.stringify(token), { headers: this.headers }).map(this.extractData); + } + hasRole(role: string): boolean { var roles = localStorage.getItem("roles") as string[] | null; if (roles) { diff --git a/src/Ombi/Controllers/IdentityController.cs b/src/Ombi/Controllers/IdentityController.cs index 34b36073e..2b4883193 100644 --- a/src/Ombi/Controllers/IdentityController.cs +++ b/src/Ombi/Controllers/IdentityController.cs @@ -9,15 +9,22 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Ombi.Attributes; +using Ombi.Config; using Ombi.Core.Claims; using Ombi.Core.Helpers; using Ombi.Core.Models.UI; +using Ombi.Core.Settings; using Ombi.Models; using Ombi.Models.Identity; +using Ombi.Notifications; +using Ombi.Notifications.Models; +using Ombi.Settings.Settings.Models; +using Ombi.Settings.Settings.Models.Notifications; using Ombi.Store.Entities; -using IdentityResult = Ombi.Models.Identity.IdentityResult; +using OmbiIdentityResult = Ombi.Models.Identity.IdentityResult; namespace Ombi.Controllers { @@ -28,16 +35,27 @@ namespace Ombi.Controllers [PowerUser] public class IdentityController : BaseV1ApiController { - public IdentityController(UserManager user, IMapper mapper, RoleManager rm) + public IdentityController(UserManager user, IMapper mapper, RoleManager rm, IEmailProvider prov, + ISettingsService s, + ISettingsService c, + IOptions userSettings) { UserManager = user; Mapper = mapper; RoleManager = rm; + EmailProvider = prov; + EmailSettings = s; + CustomizationSettings = c; + UserSettings = userSettings; } private UserManager UserManager { get; } private RoleManager RoleManager { get; } private IMapper Mapper { get; } + private IEmailProvider EmailProvider { get; } + private ISettingsService EmailSettings { get; } + private ISettingsService CustomizationSettings { get; } + private IOptions UserSettings { get; } /// /// This is what the Wizard will call when creating the user for the very first time. @@ -167,7 +185,7 @@ namespace Ombi.Controllers /// The user. /// [HttpPost] - public async Task CreateUser([FromBody] UserViewModel user) + public async Task CreateUser([FromBody] UserViewModel user) { if (!EmailValidator.IsValidEmail(user.EmailAddress)) { @@ -185,7 +203,7 @@ namespace Ombi.Controllers if (!userResult.Succeeded) { // We did not create the user - return new IdentityResult + return new OmbiIdentityResult { Errors = userResult.Errors.Select(x => x.Description).ToList() }; @@ -201,13 +219,13 @@ namespace Ombi.Controllers messages.AddRange(errors.Errors.Select(x => x.Description).ToList()); } - return new IdentityResult + return new OmbiIdentityResult { Errors = messages }; } - return new IdentityResult + return new OmbiIdentityResult { Successful = true }; @@ -220,7 +238,7 @@ namespace Ombi.Controllers /// [HttpPut("local")] [Authorize] - public async Task UpdateLocalUser([FromBody] UpdateLocalUserModel ui) + public async Task UpdateLocalUser([FromBody] UpdateLocalUserModel ui) { if (string.IsNullOrEmpty(ui.CurrentPassword)) { @@ -260,7 +278,7 @@ namespace Ombi.Controllers var updateResult = await UserManager.UpdateAsync(user); if (!updateResult.Succeeded) { - return new IdentityResult + return new OmbiIdentityResult { Errors = updateResult.Errors.Select(x => x.Description).ToList() }; @@ -272,13 +290,13 @@ namespace Ombi.Controllers if (!result.Succeeded) { - return new IdentityResult + return new OmbiIdentityResult { Errors = result.Errors.Select(x => x.Description).ToList() }; } } - return new IdentityResult + return new OmbiIdentityResult { Successful = true }; @@ -291,7 +309,7 @@ namespace Ombi.Controllers /// The user. /// [HttpPut] - public async Task UpdateUser([FromBody] UserViewModel ui) + public async Task UpdateUser([FromBody] UserViewModel ui) { if (!EmailValidator.IsValidEmail(ui.EmailAddress)) { @@ -304,7 +322,7 @@ namespace Ombi.Controllers var updateResult = await UserManager.UpdateAsync(user); if (!updateResult.Succeeded) { - return new IdentityResult + return new OmbiIdentityResult { Errors = updateResult.Errors.Select(x => x.Description).ToList() }; @@ -327,13 +345,13 @@ namespace Ombi.Controllers messages.AddRange(errors.Errors.Select(x => x.Description).ToList()); } - return new IdentityResult + return new OmbiIdentityResult { Errors = messages }; } - return new IdentityResult + return new OmbiIdentityResult { Successful = true }; @@ -346,7 +364,7 @@ namespace Ombi.Controllers /// The user. /// [HttpDelete("{userId}")] - public async Task DeleteUser(string userId) + public async Task DeleteUser(string userId) { var userToDelete = await UserManager.Users.FirstOrDefaultAsync(x => x.Id == userId); @@ -355,13 +373,13 @@ namespace Ombi.Controllers var result = await UserManager.DeleteAsync(userToDelete); if (result.Succeeded) { - return new IdentityResult + return new OmbiIdentityResult { Successful = true }; } - return new IdentityResult + return new OmbiIdentityResult { Errors = result.Errors.Select(x => x.Description).ToList() }; @@ -391,6 +409,86 @@ namespace Ombi.Controllers return claims; } + /// + /// Send out the email with the reset link + /// + /// + /// + [HttpPost("reset")] + [AllowAnonymous] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task SubmitResetPassword([FromBody]SubmitPasswordReset email) + { + // Check if account exists + var user = await UserManager.FindByEmailAsync(email.Email); + + var defaultMessage = new OmbiIdentityResult + { + Successful = true, + Errors = new List { "If this account exists you should recieve a password reset link." } + }; + + if (user == null) + { + return defaultMessage; + } + + // We have the user + var token = await UserManager.GeneratePasswordResetTokenAsync(user); + + // We now need to email the user with this token + var emailSettings = await EmailSettings.GetSettingsAsync(); + var customizationSettings = await CustomizationSettings.GetSettingsAsync(); + var appName = (string.IsNullOrEmpty(customizationSettings.ApplicationName) + ? "Ombi" + : customizationSettings.ApplicationName); + await EmailProvider.Send(new NotificationMessage + { + To = user.Email, + Subject = $"{appName} Password Reset", + Message = $"Hello {user.UserName},
You recently made a request to reset your {appName} account. Please click the link below to complete the process.

" + + $" Reset " + }, emailSettings); + + return defaultMessage; + } + + /// + /// Resets the password + /// + /// + /// + [HttpPost("resetpassword")] + [AllowAnonymous] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task ResetPassword(ResetPasswordToken token) + { + var user = await UserManager.FindByEmailAsync(token.Email); + + if (user == null) + { + return new OmbiIdentityResult + { + Successful = false, + Errors = new List { "Please check you email." } + }; + } + + var tokenValid = await UserManager.ResetPasswordAsync(user, token.Token, token.Password); + + if (tokenValid.Succeeded) + { + return new OmbiIdentityResult + { + Successful = true, + }; + } + return new OmbiIdentityResult + { + Errors = tokenValid.Errors.Select(x => x.Description).ToList() + }; + } + private async Task> AddRoles(IEnumerable roles, OmbiUser ombiUser) { var roleResult = new List(); @@ -404,9 +502,9 @@ namespace Ombi.Controllers return roleResult; } - private IdentityResult Error(string message) + private OmbiIdentityResult Error(string message) { - return new IdentityResult + return new OmbiIdentityResult { Errors = new List { message } }; diff --git a/src/Ombi/Models/Identity/ResetPasswordToken.cs b/src/Ombi/Models/Identity/ResetPasswordToken.cs new file mode 100644 index 000000000..8cb52a1df --- /dev/null +++ b/src/Ombi/Models/Identity/ResetPasswordToken.cs @@ -0,0 +1,9 @@ +namespace Ombi.Models.Identity +{ + public class ResetPasswordToken + { + public string Token { get; set; } + public string Email { get; set; } + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/src/Ombi/Models/Identity/SubmitPasswordReset.cs b/src/Ombi/Models/Identity/SubmitPasswordReset.cs new file mode 100644 index 000000000..288be278f --- /dev/null +++ b/src/Ombi/Models/Identity/SubmitPasswordReset.cs @@ -0,0 +1,7 @@ +namespace Ombi.Models.Identity +{ + public class SubmitPasswordReset + { + public string Email { get; set; } + } +} \ No newline at end of file