diff --git a/build.gradle b/build.gradle index 8d5421d..c4b06dd 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'pl.databucket' -version = '3.3.3' +version = '3.4.0' targetCompatibility = 8 sourceCompatibility = 8 @@ -34,6 +34,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa:2.5.7' implementation 'org.springframework.boot:spring-boot-starter-security:2.5.7' implementation 'org.springframework.boot:spring-boot-starter-actuator:2.5.7' + implementation 'org.springframework.boot:spring-boot-starter-mail:2.5.7' implementation 'org.springframework:spring-tx:5.3.9' implementation 'org.postgresql:postgresql:42.2.23.jre7' implementation 'com.fasterxml.jackson.core:jackson-core:2.13.1' @@ -50,17 +51,17 @@ dependencies { compileOnly 'org.projectlombok:lombok:1.18.20' annotationProcessor 'org.projectlombok:lombok' - testImplementation('org.springframework.boot:spring-boot-starter-test:2.5.4') {exclude group: 'junit', module: 'junit'} +// testImplementation('org.springframework.boot:spring-boot-starter-test:2.5.4') {exclude group: 'junit', module: 'junit'} // JUnit dependencies - testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2' - testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.7.2' - testRuntimeOnly 'org.junit.platform:junit-platform-commons:1.7.2' +// testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2' +// testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.7.2' +// testRuntimeOnly 'org.junit.platform:junit-platform-commons:1.7.2' // Spock - Mandatory dependencies - testImplementation 'org.codehaus.groovy:groovy-all:3.0.8' - testImplementation 'org.spockframework:spock-core:2.0-groovy-3.0' - testImplementation 'org.spockframework:spock-spring:2.0-groovy-3.0' +// testImplementation 'org.codehaus.groovy:groovy-all:3.0.8' +// testImplementation 'org.spockframework:spock-core:2.0-groovy-3.0' +// testImplementation 'org.spockframework:spock-spring:2.0-groovy-3.0' } test { diff --git a/frontend/package.json b/frontend/package.json index 82ad140..6932f0f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "homepage": ".", "name": "databucket", - "version": "3.3.3", + "version": "3.4.0", "private": true, "dependencies": { "@material-ui/core": "4.12.4", diff --git a/frontend/src/components/dialogs/InfoDialog.jsx b/frontend/src/components/dialogs/InfoDialog.jsx index 7f3abbf..ebe6229 100644 --- a/frontend/src/components/dialogs/InfoDialog.jsx +++ b/frontend/src/components/dialogs/InfoDialog.jsx @@ -62,7 +62,7 @@ export default function InfoDialog() { >
- Version: 3.3.3 + Version: 3.4.0 www.databucket.pl
Source code
Documentation
diff --git a/frontend/src/components/login/ChangePasswordPage.jsx b/frontend/src/components/login/ChangePasswordPage.jsx index b2184f9..1ae8f1f 100644 --- a/frontend/src/components/login/ChangePasswordPage.jsx +++ b/frontend/src/components/login/ChangePasswordPage.jsx @@ -13,6 +13,7 @@ import {fetchHelper, handleErrors} from "../../utils/FetchHelper"; import {Redirect} from "react-router-dom"; import {getProjectDataPath} from "../../route/AppRouter"; import {getBaseUrl} from "../../utils/UrlBuilder"; +import Link from "@material-ui/core/Link"; const initialState = { password: "", @@ -21,6 +22,7 @@ const initialState = { }; export default function ChangePasswordPage() { + const [back, setBack] = useState(false); const [{password, newPassword, newPasswordConfirmation}, setState] = useState(initialState); const [messageBox, setMessageBox] = useState({open: false, severity: 'error', title: '', message: ''}); const [redirect, setRedirect] = useState(false); @@ -82,6 +84,9 @@ export default function ChangePasswordPage() { getPasswordStrength(value); }; + if (back) + return (); + else return ( redirect === true ? ( @@ -91,6 +96,16 @@ export default function ChangePasswordPage() { Change password + {/*// additional control to block setting default password by Chrome*/} + + + Current password
+
+ { + setBack(true); + }} + > + Back + +
{ + let resultOk = true; + fetch(getConfirmationUrl(inputParams[0]), getGetOptions()) + .then(handleErrors) + .catch(error => { + setMessageBox({open: true, severity: 'error', title: 'Error', message: error}); + resultOk = false; + }) + .then(response => { + if (resultOk) { + setDone(true); + setTimeout(() => { + setRedirect(true); + }, 6000) + } + }); + }, [inputParams]); + + if (redirect) + return (); + else + return ( +
+ {} + + + Confirmation + +
+ + {!done ? "Processing..." : "Check your email inbox."} + + + setMessageBox({...messageBox, open: false})} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/login/ForgotPasswordPage.css b/frontend/src/components/login/ForgotPasswordPage.css new file mode 100644 index 0000000..890156a --- /dev/null +++ b/frontend/src/components/login/ForgotPasswordPage.css @@ -0,0 +1,57 @@ +.ContainerClass { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 10vh; +} + +.PaperClass { + margin-top: 40px; + display: flex; + min-width: 370px; + max-width: 370px; + min-height: 500px; + max-height: 700px; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 20px 0px 0px 0px; +} + +.Title { + padding: 30px; +} + +.Description { + padding: 30px; +} + +.TitleGrid { + padding: 30px; + width: 100%; + /*overflow: auto;*/ +} + +.Button { + width: 100%; + margin-top: 10px; + padding: 20px; +} + +.EmailInputText { + height: 70px; + width: 85%; + overflow: auto; +} + +.BackLink { + width: 100%; + margin-top: 50px; + margin-left: 40px; + margin-bottom: 10px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; +} \ No newline at end of file diff --git a/frontend/src/components/login/ForgotPasswordPage.jsx b/frontend/src/components/login/ForgotPasswordPage.jsx new file mode 100644 index 0000000..32bb223 --- /dev/null +++ b/frontend/src/components/login/ForgotPasswordPage.jsx @@ -0,0 +1,104 @@ +import React, {useState} from "react"; +import "./ForgotPasswordPage.css"; +import Button from "@material-ui/core/Button"; +import Logo from "../../images/databucket-logo.png"; +import {Input, InputLabel, Paper} from "@material-ui/core"; +import Typography from "@material-ui/core/Typography"; +import FormControl from "@material-ui/core/FormControl"; +import {MessageBox} from "../utils/MessageBox"; +import Link from "@material-ui/core/Link"; +import {Redirect} from "react-router-dom"; +import {getBaseUrl, getContextPath} from "../../utils/UrlBuilder"; +import {handleLoginErrors} from "../../utils/FetchHelper"; +import {validateEmail} from "../../utils/Misc"; + +export default function ForgotPasswordPage() { + + const [back, setBack] = useState(false); + const [email, setEmail] = useState(""); + const [messageBox, setMessageBox] = useState({open: false, severity: 'error', title: '', message: ''}); + + const onChange = e => { + setEmail(e.target.value); + }; + + const handleReset = () => { + fetch(getBaseUrl('public/forgot-password'), { + method: 'POST', + body: JSON.stringify({email: email, url: window.location.origin + getContextPath() + "/confirmation/forgot-password/"}), + headers: {'Content-Type': 'application/json'} + }) + .then(handleLoginErrors) + .then(response => { + setMessageBox({open: true, severity: 'info', title: 'Send confirmation email', message: null}); + }).catch(error => { + setMessageBox({open: true, severity: 'error', title: 'Sending confirmation password failed', message: error}); + } + ); + } + + const handleKeypress = e => { + if (e.key === 'Enter') + handleReset(); + }; + + if (back) + return (); + else + return ( +
+ {} + + + Forgot your password? + + + Please enter the email address for your account. + A verification link will be sent to you. + Once you have received the verification link, + you will be able to choose a new password for your account. + + + Email + handleKeypress(event)} + /> + +
+ +
+
+ { + setBack(true); + }} + > + Back + +
+
+ setMessageBox({...messageBox, open: false})} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/login/LoginPage.css b/frontend/src/components/login/LoginPage.css index 3e304f8..cc6a8da 100644 --- a/frontend/src/components/login/LoginPage.css +++ b/frontend/src/components/login/LoginPage.css @@ -7,16 +7,16 @@ } .PaperClass { - margin-top: 30px; + margin-top: 40px; display: flex; min-width: 370px; max-width: 370px; - min-height: 400px; - max-height: 600px; + min-height: 500px; + max-height: 700px; flex-direction: column; align-items: center; justify-content: flex-start; - padding: 20px 10px 10px 10px; + padding: 20px 0px 0px 0px; } .Title { @@ -29,8 +29,25 @@ /*overflow: auto;*/ } -.Button { - padding: 35px; +.ButtonLogin { + width: 100%; + margin-top: 50px; + padding: 20px; +} + +.ForgotPasswordLink { + width: 100%; + display: flex; + margin-top: -15px; + margin-right: 45px; + flex-direction: column; + align-items: flex-end; + justify-content: flex-end; + z-index: 1; +} + +.RegistrationLink { + padding-top: 70px; } .ProjectsList { @@ -40,6 +57,6 @@ .LoginInputText { height: 70px; - width: 70%; + width: 85%; overflow: auto; } \ No newline at end of file diff --git a/frontend/src/components/login/LoginPage.jsx b/frontend/src/components/login/LoginPage.jsx index f183795..9dfda40 100644 --- a/frontend/src/components/login/LoginPage.jsx +++ b/frontend/src/components/login/LoginPage.jsx @@ -20,7 +20,7 @@ import { setActiveProjectId, setRoles, setUsername, hasSuperRole, hasMemberRole, hasAdminRole, hasToken, hasProject, logOut, getPathname, setPathname } from '../../utils/ConfigurationStorage'; -import {Link, Redirect} from 'react-router-dom'; +import {Redirect} from 'react-router-dom'; import FormControl from "@material-ui/core/FormControl"; import IconButton from "@material-ui/core/IconButton"; import {Visibility, VisibilityOff} from "@material-ui/icons"; @@ -28,20 +28,24 @@ import Grid from "@material-ui/core/Grid"; import {MessageBox} from "../utils/MessageBox"; import {sortByKey} from "../../utils/JsonHelper"; import {getManagementProjectsPath, getProjectDataPath} from "../../route/AppRouter"; -import {getBaseUrl, getContextPath} from "../../utils/UrlBuilder"; +import {getBaseUrl} from "../../utils/UrlBuilder"; import ReactGA from 'react-ga'; +import MaterialLink from "@material-ui/core/Link"; +import {Link} from "react-router-dom"; const initialState = { username: "", password: "", projects: null, + resetPassword: false, changePassword: false, + register: false, showPassword: false }; export default function LoginPage() { - const [{username, password, projects, changePassword, showPassword}, setState] = useState(initialState); + const [{username, password, projects, resetPassword, changePassword, register, showPassword}, setState] = useState(initialState); const [messageBox, setMessageBox] = useState({open: false, severity: 'error', title: '', message: ''}); const onChange = e => { @@ -67,7 +71,7 @@ export default function LoginPage() { }; const signIn = (username, password, projectId) => { - fetch(getBaseUrl('public/signin'), { + fetch(getBaseUrl('public/sign-in'), { method: 'POST', body: JSON.stringify(projectId == null ? {username, password} : {username, password, projectId}), headers: {'Content-Type': 'application/json'} @@ -133,6 +137,7 @@ export default function LoginPage() { -
+
+ { + setState(prevState => ({...prevState, resetPassword: true})); + }} + > + Forgot your password? + +
+
+
+ { + setState(prevState => ({...prevState, register: true})); + }} + > + Don't have an account? + +
); } @@ -215,7 +244,11 @@ export default function LoginPage() { } const getSwitchParam = () => { - if (changePassword === true) { + if (register === true) { + return 6; + } else if (resetPassword === true) { + return 5; + } else if (changePassword === true) { return 4; } else if (projects != null && projects.length > 0) { return 3; @@ -230,6 +263,10 @@ export default function LoginPage() { const paper = () => { switch (getSwitchParam()) { + case 6: + return redirectTo("/sign-up"); + case 5: + return redirectTo("/forgot-password"); case 4: return redirectTo("/change-password"); case 3: @@ -254,7 +291,7 @@ export default function LoginPage() {
{} {paper()} - 3.3.3 + 3.4.0 setMessageBox({...messageBox, open: false})} diff --git a/frontend/src/components/login/SignUpPage.css b/frontend/src/components/login/SignUpPage.css new file mode 100644 index 0000000..42d5d6c --- /dev/null +++ b/frontend/src/components/login/SignUpPage.css @@ -0,0 +1,69 @@ +.ContainerClass { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 10vh; +} + +.PaperClass { + margin-top: 40px; + display: flex; + min-width: 370px; + max-width: 370px; + min-height: 500px; + max-height: 700px; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 20px 0px 0px 0px; +} + +.Title { + padding: 30px; +} + +.Description { + padding: 30px; +} + +.TitleGrid { + padding: 30px; + width: 100%; + /*overflow: auto;*/ +} + +.Button { + width: 100%; + margin-top: 10px; + padding: 20px; +} + +.EmailInputText { + height: 70px; + width: 85%; + overflow: auto; +} + +.UsernameInputText { + height: 70px; + width: 85%; + overflow: auto; +} + +.PasswordText { + height: 70px; + width: 85%; + overflow: auto; +} + +.BackLink { + width: 100%; + margin-top: 50px; + margin-left: 40px; + margin-bottom: 10px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; +} \ No newline at end of file diff --git a/frontend/src/components/login/SignUpPage.jsx b/frontend/src/components/login/SignUpPage.jsx new file mode 100644 index 0000000..baaf72d --- /dev/null +++ b/frontend/src/components/login/SignUpPage.jsx @@ -0,0 +1,200 @@ +import React, {useState} from "react"; +import "./SignUpPage.css"; +import Button from "@material-ui/core/Button"; +import Logo from "../../images/databucket-logo.png"; +import {Input, InputLabel, Paper} from "@material-ui/core"; +import Typography from "@material-ui/core/Typography"; +import FormControl from "@material-ui/core/FormControl"; +import {MessageBox} from "../utils/MessageBox"; +import Link from "@material-ui/core/Link"; +import {Redirect} from "react-router-dom"; +import {validateEmail} from "../../utils/Misc"; +import {getBaseUrl, getContextPath} from "../../utils/UrlBuilder"; +import {handleLoginErrors} from "../../utils/FetchHelper"; + +export default function SignUpPage() { + + const [back, setBack] = useState(false); + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirmation, setPasswordConfirmation] = useState(""); + const [messageBox, setMessageBox] = useState({open: false, severity: 'error', title: '', message: ''}); + + const handleSetUsername = e => { + setUsername(e.target.value); + checkUsername(e.target.value); + }; + + const handleSetEmail = e => { + setEmail(e.target.value); + checkEmail(e.target.value); + }; + + const handlePassword = e => { + setPassword(e.target.value); + getPasswordStrength(e.target.value); + }; + + const handlePasswordConfirmation = e => { + setPasswordConfirmation(e.target.value); + checkPasswordConfirmation(e.target.value); + }; + + const handleSubmit = () => { + fetch(getBaseUrl('public/sign-up'), { + method: 'POST', + body: JSON.stringify({username: username, email: email, password: password, url: window.location.origin + getContextPath() + "/confirmation/sign-up/"}), + headers: {'Content-Type': 'application/json'} + }) + .then(handleLoginErrors) + .then(response => { + setMessageBox({open: true, severity: 'info', title: 'Send confirmation email', message: null}); + }).catch(error => { + setMessageBox({open: true, severity: 'error', title: 'Registration failed', message: error}); + } + ); + } + + const getPasswordStrength = (pwd) => { + const strongRegex = new RegExp("^(?=.{14,})(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*\\W).*$", "g"); + const mediumRegex = new RegExp("^(?=.{10,})(((?=.*[A-Z])(?=.*[a-z]))|((?=.*[A-Z])(?=.*[0-9]))|((?=.*[a-z])(?=.*[0-9]))).*$", "g"); + const enoughRegex = new RegExp("(?=.{8,}).*", "g"); + + if (strongRegex.test(pwd)) { + setMessageBox({open: true, severity: 'success', title: 'Strong password.', message: ''}); + } else if (mediumRegex.test(pwd)) { + setMessageBox({open: true, severity: 'info', title: 'Medium password.', message: ''}); + } else if (enoughRegex.test(pwd)) { + setMessageBox({open: true, severity: 'warning', title: 'Weak password!', message: ''}); + } else { + setMessageBox({open: true, severity: 'error', title: 'Very weak password!!!', message: ''}); + } + } + + const checkPasswordConfirmation = (passwordConfirmation) => { + if (passwordConfirmation !== password) + setMessageBox({open: true, severity: 'error', title: 'Your password and confirmation password do not match.', message: ''}); + else + setMessageBox({open: true, severity: 'success', title: 'Your password and confirmation password match.', message: ''}); + } + + const checkUsername = (username) => { + if (!(username.length >=3 && username.length < 30)) + setMessageBox({open: true, severity: 'info', title: 'Please enter a username between 3 and 30 characters.', message: ''}); + } + + const checkEmail = (email) => { + if (!validateEmail(email)) + setMessageBox({open: true, severity: 'error', title: 'Invalid email address.', message: ''}); + } + + const handleKeypress = e => { + if (e.key === 'Enter') + handleSubmit(); + }; + + if (back) + return (); + else + return ( +
+ {} + + + Sign up + + + You want to create a new account? + Send required fields and wait for the confirmation link. + Until you confirm your registration, your account will be inactive. + + {/*// additional control to block setting default password by Chrome*/} + + + + + Username + handleKeypress(event)} + /> + + + Email + handleKeypress(event)} + /> + + + Password + + + + Confirm password + + +
+ +
+
+ { + setBack(true); + }} + > + Back + +
+
+ setMessageBox({...messageBox, open: false})} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/management/ProjectsTab.jsx b/frontend/src/components/management/ProjectsTab.jsx index f4e00c1..50135f5 100644 --- a/frontend/src/components/management/ProjectsTab.jsx +++ b/frontend/src/components/management/ProjectsTab.jsx @@ -54,7 +54,7 @@ export default function ProjectsTab() { const {templates, fetchTemplates, notifyTemplates} = templatesContext; const rolesContext = useContext(RolesContext); const {roles, fetchRoles} = rolesContext; - const changeableFields = ['id', 'enabled', 'name', 'description', 'usersIds', 'templatesIds', 'expirationDate']; + const changeableFields = ['id', 'publicVisible', 'enabled', 'name', 'description', 'usersIds', 'templatesIds', 'expirationDate']; const projectSpecification = { name: {title: 'Name', check: ['notEmpty', 'min1', 'max30']}, description: {title: 'Description', check: ['max250']} @@ -116,6 +116,13 @@ export default function ProjectsTab() { tableRef={tableRef} columns={[ getColumnId(), + { + title: 'Public', + field: 'publicVisible', + type: 'boolean', + width: '1%', + cellStyle: {width: '1%'} + }, getColumnEnabled(), getColumnName(), getColumnDescription('20%'), diff --git a/frontend/src/components/management/UsersTab.jsx b/frontend/src/components/management/UsersTab.jsx index 764dbbd..2f7b9cd 100644 --- a/frontend/src/components/management/UsersTab.jsx +++ b/frontend/src/components/management/UsersTab.jsx @@ -50,9 +50,10 @@ export default function UsersTab() { const {roles, fetchRoles} = rolesContext; const projectsContext = useContext(ProjectsContext); const {projects, fetchProjects, notifyProjects} = projectsContext; - const changeableFields = ['id', 'username', 'description', 'enabled', 'expirationDate', 'rolesIds', 'projectsIds']; + const changeableFields = ['id', 'username', 'email', 'description', 'enabled', 'expirationDate', 'rolesIds', 'projectsIds']; const userSpecification = { username: {title: 'Username', check: ['notEmpty', 'min1', 'max30']}, + email: {title: 'Email', check: ['email']}, description: {title: 'Description', check: ['max200']} }; @@ -86,6 +87,7 @@ export default function UsersTab() { {title: 'State', filtering: false, cellStyle: { width: '1%'}, editable: 'never', searchable: false, sorting: false, render: (rowData) => getUserIcon(rowData)}, getColumnEnabled(), {title: 'Name', field: 'username', editable: 'onAdd', filtering: true}, + {title: 'Email', field: 'email', filtering: true}, getColumnDescription('20%'), { title: 'Roles', field: 'rolesIds', filtering: false, sorting: false, diff --git a/frontend/src/route/AppRouter.js b/frontend/src/route/AppRouter.js index c978c71..0cc44d5 100644 --- a/frontend/src/route/AppRouter.js +++ b/frontend/src/route/AppRouter.js @@ -11,6 +11,9 @@ import {getActiveProjectId, hasProject, setPathname} from "../utils/Configuratio import _ProjectRouteInternal from "../components/data/_ProjectRouteInternal"; import ChangePasswordRoute from "./ChangePasswordRoute"; import {getContextPath} from "../utils/UrlBuilder"; +import ForgotPasswordPage from "../components/login/ForgotPasswordPage"; +import SignUpPage from "../components/login/SignUpPage"; +import ConfirmationPage from "../components/login/ConfirmationPage"; export default function AppRouter() { @@ -24,6 +27,9 @@ export default function AppRouter() { + + + diff --git a/frontend/src/utils/JsonHelper.js b/frontend/src/utils/JsonHelper.js index bb8d18b..5aa5dd8 100644 --- a/frontend/src/utils/JsonHelper.js +++ b/frontend/src/utils/JsonHelper.js @@ -22,6 +22,14 @@ export const getSelectedValues = (data, keys) => { return result; } +const validateEmail = (email) => { + return String(email) + .toLowerCase() + .match( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + ); +}; + export const validateItem = (data, specification) => { // exampleSpecification = { // name: {title: 'Name', check: ['notEmpty', 'min3', 'max5']}, @@ -88,6 +96,11 @@ export const validateItem = (data, specification) => { if (data[key] === 'select' && data['enumId'] == null) message += `>>> Enum must not be empty for 'Enum' type! `; } + + if (validation === 'email') { + if (!validateEmail(data[key])) + message += `>>> Invalid email address! `; + } } } } diff --git a/frontend/src/utils/Misc.js b/frontend/src/utils/Misc.js new file mode 100644 index 0000000..95711bb --- /dev/null +++ b/frontend/src/utils/Misc.js @@ -0,0 +1,4 @@ +export const validateEmail = (email) => { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); +} \ No newline at end of file diff --git a/frontend/src/utils/UrlBuilder.js b/frontend/src/utils/UrlBuilder.js index 2d658c3..6d4a7b4 100644 --- a/frontend/src/utils/UrlBuilder.js +++ b/frontend/src/utils/UrlBuilder.js @@ -1,7 +1,6 @@ export const getOrigin = () => { - const origin = window.location.origin; - // const origin = 'http://localhost:8080'; - + // const origin = window.location.origin; + const origin = 'http://localhost:8080'; return origin + getContextPath(); } @@ -14,6 +13,10 @@ export const getContextPath = () => { return ""; } +export const getConfirmationUrl = (secureLink) => { + return `${getOrigin()}/api/public/confirmation/${secureLink}`; +} + export const getDataUrl = (bucket) => { return `${getOrigin()}/api/bucket/${bucket.name}`; } diff --git a/src/main/java/pl/databucket/server/configuration/SwaggerConfiguration.java b/src/main/java/pl/databucket/server/configuration/SwaggerConfiguration.java index 8a70017..9077795 100644 --- a/src/main/java/pl/databucket/server/configuration/SwaggerConfiguration.java +++ b/src/main/java/pl/databucket/server/configuration/SwaggerConfiguration.java @@ -49,7 +49,7 @@ public Docket confDataContext() { private ApiInfo apiInfo() { return new ApiInfoBuilder() .title("Databucket API") - .version("3.3.3") + .version("3.4.0") .build(); } } \ No newline at end of file diff --git a/src/main/java/pl/databucket/server/controller/ManageUserController.java b/src/main/java/pl/databucket/server/controller/ManageUserController.java index 440dc5a..4e498f9 100644 --- a/src/main/java/pl/databucket/server/controller/ManageUserController.java +++ b/src/main/java/pl/databucket/server/controller/ManageUserController.java @@ -75,7 +75,7 @@ public ResponseEntity modifyUser(@Valid @RequestBody ManageUserDtoRequest use @PostMapping(value = "/password/reset") public ResponseEntity resetPassword(@Valid @RequestBody AuthReqDTO authDtoRequest) { try { - manageUserService.resetPassword(authDtoRequest); + manageUserService.resetAndSendPassword(authDtoRequest); return new ResponseEntity<>(null, HttpStatus.OK); } catch (IllegalArgumentException e1) { return exceptionFormatter.customException(e1, HttpStatus.NOT_ACCEPTABLE); diff --git a/src/main/java/pl/databucket/server/controller/PublicController.java b/src/main/java/pl/databucket/server/controller/PublicController.java index 7a6eb5c..5dc384c 100644 --- a/src/main/java/pl/databucket/server/controller/PublicController.java +++ b/src/main/java/pl/databucket/server/controller/PublicController.java @@ -6,30 +6,29 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.mail.MailSendException; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.*; -import pl.databucket.server.dto.AuthReqDTO; -import pl.databucket.server.dto.AuthRespDTO; -import pl.databucket.server.dto.AuthProjectDTO; -import pl.databucket.server.dto.DataGetDTO; +import pl.databucket.server.dto.*; import pl.databucket.server.entity.Project; import pl.databucket.server.entity.User; import pl.databucket.server.exception.ExceptionFormatter; +import pl.databucket.server.exception.ForbiddenRepetitionException; import pl.databucket.server.repository.UserRepository; -import pl.databucket.server.response.MessageResponse; import pl.databucket.server.security.TokenProvider; -import pl.databucket.server.service.data.DataService; -import pl.databucket.server.service.data.QueryRule; +import pl.databucket.server.service.ManageUserService; +import javax.mail.AuthenticationFailedException; +import javax.mail.MessagingException; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; -@Api(tags="PUBLIC") +@Api(tags = "PUBLIC") @CrossOrigin(origins = "*") @RestController @RequestMapping("/api/public") @@ -42,7 +41,7 @@ public class PublicController { private UserRepository userRepository; @Autowired - private DataService dataService; + private ManageUserService manageUserService; @Autowired private ModelMapper modelMapper; @@ -60,9 +59,9 @@ public class PublicController { @ApiResponse(code = 403, message = "Forbidden - user access has expired | the project is disabled | the project is expired | the user is not assign to given project | the user is not assign to any project"), @ApiResponse(code = 500, message = "Internal server error"), }) - @PostMapping(value = "/signin", produces = MediaType.APPLICATION_JSON_VALUE) + @PostMapping(value = {"/sign-in", "/signin"}, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity signIn( - @ApiParam(value="payload - username (required), password (required), projectId (optional)", required = true) @RequestBody AuthReqDTO authReqDTO) { + @ApiParam(value = "payload - username (required), password (required), projectId (optional)", required = true) @RequestBody AuthReqDTO authReqDTO) { User user = userRepository.findByUsername(authReqDTO.getUsername()); if (user == null) return exceptionFormatter.customException(new UsernameNotFoundException("Bad credentials"), HttpStatus.UNAUTHORIZED); @@ -177,23 +176,66 @@ public ResponseEntity signIn( } } - @ApiOperation(value = "getQuery", hidden = true) - @PostMapping(value = "/query") - public ResponseEntity getQuery(@RequestBody(required = false) DataGetDTO dataGetDto) { + @PostMapping(value = "/forgot-password", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity forgotPassword(@RequestBody ForgotPasswordReqDTO forgotPasswordReqDTO) { try { - return new ResponseEntity<>(new MessageResponse(dataService.getQuery(new QueryRule("fakeUserName", dataGetDto))), HttpStatus.OK); + manageUserService.forgotPasswordMessage(forgotPasswordReqDTO); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (MessagingException | MailSendException e) { + return exceptionFormatter.customPublicException("Mail service exception!", HttpStatus.SERVICE_UNAVAILABLE); + } catch (UsernameNotFoundException e) { + return exceptionFormatter.customPublicException(e.getMessage(), HttpStatus.UNAUTHORIZED); + } catch (ForbiddenRepetitionException e) { + return exceptionFormatter.customPublicException(e.getMessage(), HttpStatus.FORBIDDEN); } catch (Exception e) { return exceptionFormatter.defaultException(e); } } - @ApiOperation(value = "getQueryRules", hidden = true) - @PostMapping(value = "/query-rules") - public ResponseEntity getQueryRules(@RequestBody(required = false) DataGetDTO dataGetDto) { + @GetMapping(value = {"/confirmation/forgot-password/{jwts}"}, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity forgotPasswordConfirmation(@PathVariable String jwts) { try { - return new ResponseEntity<>(new QueryRule("fakeUserName", dataGetDto), HttpStatus.OK); + manageUserService.resetAndSendPassword(jwts); + return new ResponseEntity<>(HttpStatus.OK); + } catch (MessagingException | MailSendException e) { + return exceptionFormatter.customException("Mail service exception!", HttpStatus.SERVICE_UNAVAILABLE); + } catch (ForbiddenRepetitionException e) { + return exceptionFormatter.customPublicException(e.getMessage(), HttpStatus.FORBIDDEN); } catch (Exception e) { return exceptionFormatter.defaultException(e); } } + + @PostMapping(value = {"/sign-up"}, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity signUp(@RequestBody SignUpDtoRequest signUpDtoRequest) { + try { + if (userRepository.existsByUsername(signUpDtoRequest.getUsername())) + return exceptionFormatter.customPublicException("Given username already exists", HttpStatus.CONFLICT); + + if (userRepository.existsByEmail(signUpDtoRequest.getEmail())) + return exceptionFormatter.customPublicException("Given email already exists", HttpStatus.CONFLICT); + + manageUserService.signUpUser(signUpDtoRequest); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (MessagingException | MailSendException e) { + return exceptionFormatter.customException("Mail service exception!", HttpStatus.SERVICE_UNAVAILABLE); + } catch (Exception e) { + return exceptionFormatter.defaultException(e); + } + } + + @GetMapping(value = {"/confirmation/sign-up/{jwts}"}, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity signUpConfirmation(@PathVariable String jwts) { + try { + manageUserService.signUpUserConfirmation(jwts); + return new ResponseEntity<>(HttpStatus.OK); + } catch (MessagingException | MailSendException e) { + return exceptionFormatter.customException("Mail service exception!", HttpStatus.SERVICE_UNAVAILABLE); + } catch (ForbiddenRepetitionException e) { + return exceptionFormatter.customPublicException(e.getMessage(), HttpStatus.FORBIDDEN); + } catch (Exception e) { + return exceptionFormatter.defaultException(e); + } + } + } diff --git a/src/main/java/pl/databucket/server/dto/ForgotPasswordReqDTO.java b/src/main/java/pl/databucket/server/dto/ForgotPasswordReqDTO.java new file mode 100644 index 0000000..5de9c86 --- /dev/null +++ b/src/main/java/pl/databucket/server/dto/ForgotPasswordReqDTO.java @@ -0,0 +1,13 @@ +package pl.databucket.server.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class ForgotPasswordReqDTO { + private String email; + private String url; +} diff --git a/src/main/java/pl/databucket/server/dto/ManageProjectDto.java b/src/main/java/pl/databucket/server/dto/ManageProjectDto.java index a6d67ac..49044e5 100644 --- a/src/main/java/pl/databucket/server/dto/ManageProjectDto.java +++ b/src/main/java/pl/databucket/server/dto/ManageProjectDto.java @@ -18,6 +18,7 @@ public class ManageProjectDto { @Size(max = Constants.DESCRIPTION_MAX) private String description; private boolean enabled; + private boolean publicVisible; private Date expirationDate; private Set usersIds; private Set templatesIds; diff --git a/src/main/java/pl/databucket/server/dto/ManageUserDtoRequest.java b/src/main/java/pl/databucket/server/dto/ManageUserDtoRequest.java index eaeb6df..d7b3b2b 100644 --- a/src/main/java/pl/databucket/server/dto/ManageUserDtoRequest.java +++ b/src/main/java/pl/databucket/server/dto/ManageUserDtoRequest.java @@ -17,6 +17,7 @@ public class ManageUserDtoRequest { @Size(min = Constants.NAME_MIN, max = Constants.NAME_MAX) private String username; private String description; + private String email; private String password; private boolean enabled = false; private Date expirationDate; diff --git a/src/main/java/pl/databucket/server/dto/ManageUserDtoResponse.java b/src/main/java/pl/databucket/server/dto/ManageUserDtoResponse.java index 663e6ff..9310b31 100644 --- a/src/main/java/pl/databucket/server/dto/ManageUserDtoResponse.java +++ b/src/main/java/pl/databucket/server/dto/ManageUserDtoResponse.java @@ -13,6 +13,7 @@ public class ManageUserDtoResponse { private long id; private String username; private String description; + private String email; private Boolean enabled; private Date expirationDate; private Set rolesIds; diff --git a/src/main/java/pl/databucket/server/dto/SignUpDtoRequest.java b/src/main/java/pl/databucket/server/dto/SignUpDtoRequest.java new file mode 100644 index 0000000..4a48ace --- /dev/null +++ b/src/main/java/pl/databucket/server/dto/SignUpDtoRequest.java @@ -0,0 +1,21 @@ +package pl.databucket.server.dto; + +import lombok.Getter; +import lombok.Setter; +import pl.databucket.server.configuration.Constants; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Size; + +@Getter +@Setter +public class SignUpDtoRequest { + + @NotEmpty + @Size(min = Constants.NAME_MIN, max = Constants.NAME_MAX) + private String username; + private String email; + private String password; + private String url; + +} diff --git a/src/main/java/pl/databucket/server/entity/Project.java b/src/main/java/pl/databucket/server/entity/Project.java index f44b5ab..6a8703b 100644 --- a/src/main/java/pl/databucket/server/entity/Project.java +++ b/src/main/java/pl/databucket/server/entity/Project.java @@ -33,6 +33,9 @@ public class Project extends Auditable { @Temporal(TemporalType.TIMESTAMP) private Date expirationDate; + @Column + private Boolean publicVisible = false; + @Column private Boolean enabled = true; diff --git a/src/main/java/pl/databucket/server/entity/User.java b/src/main/java/pl/databucket/server/entity/User.java index 5e2a754..e607c8a 100644 --- a/src/main/java/pl/databucket/server/entity/User.java +++ b/src/main/java/pl/databucket/server/entity/User.java @@ -5,6 +5,7 @@ import pl.databucket.server.configuration.Constants; import javax.persistence.*; +import javax.validation.constraints.Email; import java.io.Serializable; import java.util.Date; import java.util.Set; @@ -30,6 +31,10 @@ public class User extends Auditable implements Serializable { @Column(length = Constants.DESCRIPTION_MAX) private String description; + @Email + @Column + private String email; + @Column private String password; @@ -43,6 +48,13 @@ public class User extends Auditable implements Serializable { @Temporal(TemporalType.TIMESTAMP) private Date expirationDate; + @Column(name = "last_send_email_forgot_password_link_date") + @Temporal(TemporalType.TIMESTAMP) + private Date lastSendEmailForgotPasswordLinkDate; + + @Column(name = "last_send_email_temp_password_date") + @Temporal(TemporalType.TIMESTAMP) + private Date lastSendEmailTempPasswordDate; @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "users_roles", diff --git a/src/main/java/pl/databucket/server/exception/ExceptionFormatter.java b/src/main/java/pl/databucket/server/exception/ExceptionFormatter.java index fcda0f1..1c21743 100644 --- a/src/main/java/pl/databucket/server/exception/ExceptionFormatter.java +++ b/src/main/java/pl/databucket/server/exception/ExceptionFormatter.java @@ -47,4 +47,10 @@ public ResponseEntity> customException(String message, HttpS return new ResponseEntity<>(response, status); } + public ResponseEntity> customPublicException(String message, HttpStatus status) { + Map response = new HashMap<>(); + response.put("message", message); + return new ResponseEntity<>(response, status); + } + } diff --git a/src/main/java/pl/databucket/server/exception/ForbiddenRepetitionException.java b/src/main/java/pl/databucket/server/exception/ForbiddenRepetitionException.java new file mode 100644 index 0000000..77446cd --- /dev/null +++ b/src/main/java/pl/databucket/server/exception/ForbiddenRepetitionException.java @@ -0,0 +1,13 @@ +package pl.databucket.server.exception; + +public class ForbiddenRepetitionException extends Exception { + + public ForbiddenRepetitionException(Exception e) { + super(e); + } + + public ForbiddenRepetitionException(String message) { + super(message); + } + +} diff --git a/src/main/java/pl/databucket/server/repository/UserRepository.java b/src/main/java/pl/databucket/server/repository/UserRepository.java index d703cf3..33a2d3d 100644 --- a/src/main/java/pl/databucket/server/repository/UserRepository.java +++ b/src/main/java/pl/databucket/server/repository/UserRepository.java @@ -10,7 +10,9 @@ @Repository public interface UserRepository extends JpaRepository { User findByUsername(String name); + User findByEmail(String email); boolean existsByUsername(String name); + boolean existsByEmail(String email); List findAllByIdIn(Iterable ids); List findAllByOrderById(); List findUsersByProjectsContainsOrderById(Project project); diff --git a/src/main/java/pl/databucket/server/security/TokenProvider.java b/src/main/java/pl/databucket/server/security/TokenProvider.java index 0e2189f..bb49b42 100644 --- a/src/main/java/pl/databucket/server/security/TokenProvider.java +++ b/src/main/java/pl/databucket/server/security/TokenProvider.java @@ -21,6 +21,7 @@ public class TokenProvider implements Serializable { public static final String AUTHORITIES_KEY = "a-key"; public static final String PROJECT_ID = "p-id"; + public static final String CONTENT = "content"; @Value("${jwt.secret}") private String singingKey; @@ -48,7 +49,7 @@ private Claims getAllClaimsFromToken(String token) { .getBody(); } - private Boolean isTokenExpired(String token) { + public Boolean isTokenExpired(String token) { final Date expiration = getExpirationDateFromToken(token); return expiration.before(new Date()); } @@ -72,6 +73,22 @@ public Boolean validateToken(String token, UserDetails userDetails) { return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); } + public String packToJwts(String content) { + return Jwts.builder() + .claim(CONTENT, content) + .signWith(SignatureAlgorithm.HS256, singingKey) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + 48 * 60 * 60 * 1000)) + .compact(); + } + + public String unpackFromJwts(String jwts) { + return Jwts.parser() + .setSigningKey(singingKey) + .parseClaimsJws(jwts) + .getBody().get(CONTENT).toString(); + } + UsernamePasswordAuthenticationToken getAuthentication(final String token, final CustomUserDetails customUserDetails) { final JwtParser jwtParser = Jwts.parser().setSigningKey(singingKey); diff --git a/src/main/java/pl/databucket/server/security/WebSecurityConfig.java b/src/main/java/pl/databucket/server/security/WebSecurityConfig.java index d41726a..dccb33f 100644 --- a/src/main/java/pl/databucket/server/security/WebSecurityConfig.java +++ b/src/main/java/pl/databucket/server/security/WebSecurityConfig.java @@ -53,7 +53,7 @@ protected void configure(HttpSecurity http) throws Exception { .authorizeRequests() .antMatchers( "/", - "/api/public/**", // public endpoint for authorization + "/api/public/**", // public endpoint "/**/static/**", "/actuator/**", "/**/favicon.ico", diff --git a/src/main/java/pl/databucket/server/service/ManageProjectService.java b/src/main/java/pl/databucket/server/service/ManageProjectService.java index eed3199..2c1c867 100644 --- a/src/main/java/pl/databucket/server/service/ManageProjectService.java +++ b/src/main/java/pl/databucket/server/service/ManageProjectService.java @@ -34,6 +34,7 @@ public Project createProject(ManageProjectDto manageProjectDto) { project.setDescription(manageProjectDto.getDescription()); project.setExpirationDate(manageProjectDto.getExpirationDate()); project.setEnabled(manageProjectDto.isEnabled()); + project.setPublicVisible(manageProjectDto.isPublicVisible()); projectRepository.saveAndFlush(project); if (manageProjectDto.getUsersIds() != null) { @@ -77,6 +78,7 @@ public Project modifyProject(ManageProjectDto manageProjectDto) throws ItemNotFo project.setName(manageProjectDto.getName()); project.setDescription(manageProjectDto.getDescription()); project.setEnabled(manageProjectDto.isEnabled()); + project.setPublicVisible(manageProjectDto.isPublicVisible()); project.setExpirationDate(manageProjectDto.getExpirationDate()); if (manageProjectDto.getUsersIds() != null) { diff --git a/src/main/java/pl/databucket/server/service/ManageUserService.java b/src/main/java/pl/databucket/server/service/ManageUserService.java index 05f505a..d79306f 100644 --- a/src/main/java/pl/databucket/server/service/ManageUserService.java +++ b/src/main/java/pl/databucket/server/service/ManageUserService.java @@ -1,20 +1,32 @@ package pl.databucket.server.service; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AccountExpiredException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; +import pl.databucket.server.configuration.Constants; import pl.databucket.server.dto.AuthReqDTO; +import pl.databucket.server.dto.ForgotPasswordReqDTO; import pl.databucket.server.dto.ManageUserDtoRequest; +import pl.databucket.server.dto.SignUpDtoRequest; import pl.databucket.server.entity.Project; +import pl.databucket.server.entity.Role; import pl.databucket.server.entity.User; +import pl.databucket.server.exception.ForbiddenRepetitionException; import pl.databucket.server.exception.ItemAlreadyExistsException; import pl.databucket.server.exception.SomeItemsNotFoundException; import pl.databucket.server.repository.ProjectRepository; import pl.databucket.server.repository.RoleRepository; import pl.databucket.server.repository.UserRepository; +import pl.databucket.server.security.TokenProvider; +import pl.databucket.server.service.mail.MailSenderService; -import java.util.HashSet; -import java.util.List; +import javax.mail.AuthenticationFailedException; +import javax.mail.MessagingException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; @Service(value = "manageUserService") @@ -32,6 +44,12 @@ public class ManageUserService { @Autowired private BCryptPasswordEncoder bcryptEncoder; + @Autowired + private TokenProvider jwtTokenUtil; + + @Autowired + private MailSenderService mailSenderService; + public List getUsers() { return userRepository.findAllByOrderById(); @@ -43,6 +61,7 @@ public User createUser(ManageUserDtoRequest manageUserDtoRequest) throws ItemAlr User newUser = new User(); newUser.setUsername(manageUserDtoRequest.getUsername()); + newUser.setEmail(manageUserDtoRequest.getEmail()); newUser.setDescription(manageUserDtoRequest.getDescription()); newUser.setEnabled(manageUserDtoRequest.isEnabled()); newUser.setExpirationDate(manageUserDtoRequest.getExpirationDate()); @@ -58,9 +77,60 @@ public User createUser(ManageUserDtoRequest manageUserDtoRequest) throws ItemAlr return userRepository.save(newUser); } + public void signUpUser(SignUpDtoRequest signUpDtoRequest) throws MessagingException { + Set roles = new HashSet<>(); + roles.add(roleRepository.findByName(Constants.ROLE_MEMBER)); + + User newUser = new User(); + newUser.setUsername(signUpDtoRequest.getUsername()); + newUser.setEmail(signUpDtoRequest.getEmail()); + newUser.setPassword(bcryptEncoder.encode(signUpDtoRequest.getPassword())); + newUser.setEnabled(false); + newUser.setChangePassword(false); + newUser.setRoles(roles); + newUser = userRepository.save(newUser); + + mailSenderService.sendConfirmationLink(newUser, signUpDtoRequest.getUrl() + jwtTokenUtil.packToJwts(signUpDtoRequest.getEmail())); + } + + public void signUpUserConfirmation(String jwts) throws ForbiddenRepetitionException, MessagingException { + if (!jwtTokenUtil.isTokenExpired(jwts)) { + String email = jwtTokenUtil.unpackFromJwts(jwts); + User user = userRepository.findByEmail(email); + + if (user.getEnabled()) + throw new ForbiddenRepetitionException("The user has been already activated"); + + user.setEnabled(true); + userRepository.save(user); + + mailSenderService.sendRegistrationConfirmation(user); + } else + throw new AccountExpiredException("The confirmation link is expired!"); + } + + public void forgotPasswordMessage(ForgotPasswordReqDTO forgotPasswordReqDTO) throws ForbiddenRepetitionException, MessagingException, UsernameNotFoundException { + if (!userRepository.existsByEmail(forgotPasswordReqDTO.getEmail())) + throw new UsernameNotFoundException("User not found! Make sure you have entered the correct email address."); + + User user = userRepository.findByEmail(forgotPasswordReqDTO.getEmail()); + + if (user.getLastSendEmailForgotPasswordLinkDate() != null) { + Instant lastSendEmailTime = user.getLastSendEmailForgotPasswordLinkDate().toInstant(); + Instant currentTime = Instant.now(); + + if (ChronoUnit.HOURS.between(lastSendEmailTime, currentTime) < 48) + throw new ForbiddenRepetitionException("The confirmation link has been send within last 48 hours. Search it in your email inbox."); + } + + mailSenderService.sendForgotPasswordLink(user, forgotPasswordReqDTO.getUrl() + jwtTokenUtil.packToJwts(forgotPasswordReqDTO.getEmail())); + user.setLastSendEmailForgotPasswordLinkDate(new Date()); + userRepository.save(user); + } public User modifyUser(ManageUserDtoRequest manageUserDtoRequest) { User user = userRepository.findByUsername(manageUserDtoRequest.getUsername()); + user.setEmail(manageUserDtoRequest.getEmail()); user.setDescription(manageUserDtoRequest.getDescription()); user.setEnabled(manageUserDtoRequest.isEnabled()); user.setExpirationDate(manageUserDtoRequest.getExpirationDate()); @@ -78,7 +148,7 @@ public User modifyUser(ManageUserDtoRequest manageUserDtoRequest) { return userRepository.save(user); } - public void resetPassword(AuthReqDTO userDto) { + public void resetAndSendPassword(AuthReqDTO userDto) { User user = userRepository.findByUsername(userDto.getUsername()); if (user != null) { user.setPassword(bcryptEncoder.encode(userDto.getPassword())); @@ -87,4 +157,39 @@ public void resetPassword(AuthReqDTO userDto) { } else throw new IllegalArgumentException("The given user does not exist."); } + + public void resetAndSendPassword(String jwts) throws ForbiddenRepetitionException, MessagingException { + if (!jwtTokenUtil.isTokenExpired(jwts)) { + String email = jwtTokenUtil.unpackFromJwts(jwts); + User user = userRepository.findByEmail(email); + + if (user.getLastSendEmailTempPasswordDate() != null) { + Instant lastSendEmailTime = user.getLastSendEmailTempPasswordDate().toInstant(); + Instant currentTime = Instant.now(); + + if (ChronoUnit.HOURS.between(lastSendEmailTime, currentTime) < 48) + throw new ForbiddenRepetitionException("The temporary password has been send within last 48 hours. Search it in your email inbox."); + } + + int length = 11; + String small_letter = "abcdefghijklmnopqrstuvwxyz"; + String numbers = "0123456789"; + + String finalString = small_letter + numbers; + Random random = new Random(); + char[] password = new char[length]; + for (int i = 0; i < length; i++) + password[i] = finalString.charAt(random.nextInt(finalString.length())); + + String newPassword = new String(password); + + mailSenderService.sendNewPassword(user, newPassword); + + user.setPassword(bcryptEncoder.encode(newPassword)); + user.setChangePassword(true); + user.setLastSendEmailTempPasswordDate(new Date()); + userRepository.save(user); + } else + throw new AccountExpiredException("The confirmation link is expired!"); + } } diff --git a/src/main/java/pl/databucket/server/service/mail/Mail.java b/src/main/java/pl/databucket/server/service/mail/Mail.java new file mode 100644 index 0000000..44d181d --- /dev/null +++ b/src/main/java/pl/databucket/server/service/mail/Mail.java @@ -0,0 +1,18 @@ +package pl.databucket.server.service.mail; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Mail { + private String address; + private String title; + private String message; + private MultipartFile file; +} diff --git a/src/main/java/pl/databucket/server/service/mail/MailSenderHandler.java b/src/main/java/pl/databucket/server/service/mail/MailSenderHandler.java new file mode 100644 index 0000000..f0868ee --- /dev/null +++ b/src/main/java/pl/databucket/server/service/mail/MailSenderHandler.java @@ -0,0 +1,51 @@ +package pl.databucket.server.service.mail; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.web.multipart.MultipartFile; + +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; +import java.io.IOException; + +public class MailSenderHandler { + + private JavaMailSender sender; + private MimeMessage message; + private MimeMessageHelper messageHelper; + + public MailSenderHandler(JavaMailSender sender) throws MessagingException { + this.sender = sender; + message = sender.createMimeMessage(); + messageHelper = new MimeMessageHelper(message, true, "UTF-8"); + } + + public void setFrom(String fromAddress) throws MessagingException { + messageHelper.setFrom(fromAddress); + } + + public void setTo(String toAddress) throws MessagingException { + messageHelper.setTo(toAddress); + } + + public void setSubject(String subject) throws MessagingException { + messageHelper.setSubject(subject); + } + + public void setText(String text, boolean useHtml) throws MessagingException { + messageHelper.setText(text, useHtml); + } + + public void setAttach(String displayFileName, MultipartFile file) throws MessagingException { + messageHelper.addAttachment(displayFileName, file); + } + + public void setInline(String contentId, MultipartFile file) throws MessagingException, IOException { + messageHelper.addInline(contentId, new ByteArrayResource(file.getBytes()), "image/jpeg"); + } + + public void send() { + sender.send(message); + } +} diff --git a/src/main/java/pl/databucket/server/service/mail/MailSenderService.java b/src/main/java/pl/databucket/server/service/mail/MailSenderService.java new file mode 100644 index 0000000..c660c3e --- /dev/null +++ b/src/main/java/pl/databucket/server/service/mail/MailSenderService.java @@ -0,0 +1,79 @@ +package pl.databucket.server.service.mail; + +import com.sun.mail.util.MailConnectException; +import lombok.AllArgsConstructor; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; +import pl.databucket.server.entity.User; + +import javax.mail.MessagingException; + +@Service +@AllArgsConstructor +public class MailSenderService { + + private JavaMailSender sender; + + public void sendConfirmationLink(User user, String link) throws MessagingException { + String htmlContent = "

Thanks for signing up to Databucket.

"; + + htmlContent += "

To confirm your registration, copy and paste the URL into your browser:

"; + htmlContent += "

" + link + "


"; + + htmlContent += "

Please note that the link will expire in 48 hours.

"; + htmlContent += "

If you didn't request a registration, don't worry. You can safely ignore this email.



"; + + MailSenderHandler mailHandler = new MailSenderHandler(sender); + mailHandler.setTo(user.getEmail()); + mailHandler.setSubject("Databucket account - registration"); + mailHandler.setText(htmlContent, true); + mailHandler.send(); + } + + public void sendRegistrationConfirmation(User user) throws MessagingException { + String htmlContent = "

Databucket account

"; + + htmlContent += "

Your account has been activated.

"; + htmlContent += "

Log in to the application and request to be assigned to the project.

"; + + MailSenderHandler mailHandler = new MailSenderHandler(sender); + mailHandler.setTo(user.getEmail()); + mailHandler.setSubject("Databucket account - activated"); + mailHandler.setText(htmlContent, true); + mailHandler.send(); + } + + public void sendForgotPasswordLink(User user, String link) throws MessagingException { + String htmlContent = "

Databucket account

"; + htmlContent += "

Forgot your password?

"; + htmlContent += "

We received a request to reset the password for your account.


"; + + htmlContent += "

To reset your password, copy and paste the URL into your browser:

"; + htmlContent += "

" + link + "


"; + + htmlContent += "

Please note that the link will expire in 48 hours.

"; + htmlContent += "

If you didn't request a reset, don't worry. You can safely ignore this email.



"; + + MailSenderHandler mailHandler = new MailSenderHandler(sender); + mailHandler.setTo(user.getEmail()); + mailHandler.setSubject("Databucket account - forgot your password?"); + mailHandler.setText(htmlContent, true); + mailHandler.send(); + } + + public void sendNewPassword(User user, String password) throws MessagingException { + String htmlContent = "

Databucket account

"; + htmlContent += "

Your password has been reset.

"; + htmlContent += "

Below you can find your temporary password:

"; + htmlContent += "

" + password + "

"; + + MailSenderHandler mailHandler = new MailSenderHandler(sender); + mailHandler.setTo(user.getEmail()); + mailHandler.setSubject("Databucket account - temporary password"); + mailHandler.setText(htmlContent, true); + mailHandler.send(); + } + + +} diff --git a/src/main/resources/application-default.yaml b/src/main/resources/application-default.yaml index ab00256..6f122b4 100644 --- a/src/main/resources/application-default.yaml +++ b/src/main/resources/application-default.yaml @@ -11,3 +11,10 @@ spring: url: jdbc:postgresql://localhost:5432/databucket username: databucket_user password: databucket_user + mail: + host: smtp.gmail.com + port: 587 + username: databucket.info@gmail.com + password: * + properties.mail.smtp.auth: true + properties.mail.smtp.starttls.enable: true diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt index 6bfe80a..61e2612 100644 --- a/src/main/resources/banner.txt +++ b/src/main/resources/banner.txt @@ -7,5 +7,5 @@ ██████╔╝██║ ██║ ██║ ██║ ██║██████╔╝╚██████╔╝╚██████╗██║ ██╗███████╗ ██║ ██████╔╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝ -Databucket version: (v3.3.3) +Databucket version: (v3.4.0) Spring Boot version:${spring-boot.formatted-version}