feature/profile-diploma-topic #1

Merged
mskobaro merged 5 commits from feature/profile-diploma-topic into master 2025-02-07 07:41:24 +03:30
19 changed files with 364 additions and 118 deletions
Showing only changes of commit 96ffb3ad41 - Show all commits

3
server/.gitignore vendored
View File

@ -1 +1,2 @@
/target
/target
/logs/app.log

View File

@ -3,26 +3,22 @@ package ru.tubryansk.tdms.config;
import jakarta.servlet.http.HttpSessionEvent;
import jakarta.servlet.http.HttpSessionListener;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
@ -34,10 +30,9 @@ import org.springframework.web.cors.CorsConfigurationSource;
import java.time.Duration;
import java.util.List;
import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;
@Configuration
@Slf4j
public class SecurityConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(
@ -50,40 +45,49 @@ public class SecurityConfiguration {
.csrf(AbstractHttpConfigurer::disable)
.cors(a -> a.configurationSource(cors))
.authenticationManager(authenticationManager)
.sessionManagement(this::configureSessionManagement)
.sessionManagement(cfg -> {
cfg.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
cfg.maximumSessions(1);
})
.build();
}
@Bean
@Profile("dev")
@Qualifier("corsConfig")
public CorsConfigurationSource corsConfigurationDev() {
public CorsConfigurationSource corsConfigurationProd(
@Value("${application.domain}") String domain,
@Value("${application.port}") String port,
@Value("${application.protocol}") String protocol,
Environment environment
) {
return request -> {
String url = StringUtils.join(protocol, "://", domain, ":", port);
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.applyPermitDefaultValues();
corsConfiguration.addAllowedMethod("DELETE");
corsConfiguration.addAllowedMethod("PUT");
corsConfiguration.addAllowedMethod("PATCH");
corsConfiguration.setMaxAge(Duration.ofDays(1));
corsConfiguration.addAllowedOrigin(url);
if (environment.matchesProfiles("dev")) {
corsConfiguration.addAllowedOrigin("http://localhost:8081");
}
corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.OPTIONS.name()));
corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
corsConfiguration.setAllowCredentials(true);
return corsConfiguration;
};
}
@Bean
@Profile("!dev")
@Qualifier("corsConfig")
public CorsConfigurationSource corsConfigurationProd(
@Value("${application.domain}") String domain,
@Value("${application.port}") String port,
@Value("${application.protocol}") String protocol
) {
return request -> {
String url = StringUtils.join(protocol, "://", domain, ":", port);
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setMaxAge(Duration.ofHours(1));
corsConfiguration.addAllowedOrigin(url);
corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name()));
// corsConfiguration.setAllowedHeaders();
return corsConfiguration;
public HttpSessionListener httpSessionListener() {
return new HttpSessionListener() {
@Override
public void sessionCreated(HttpSessionEvent se) {
log.debug("Session created: {}, user {}", se.getSession().getId(), SecurityContextHolder.getContext().getAuthentication().getName());
}
@Override
public void sessionDestroyed(HttpSessionEvent se) {
log.debug("Session destroyed: {}, user: {}", se.getSession().getId(), SecurityContextHolder.getContext().getAuthentication().getName());
}
};
}
@ -97,29 +101,6 @@ public class SecurityConfiguration {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// @Bean
// @Profile("dev")
// public HttpSessionListener autoAuthenticateUnderAdmin(AuthenticationManager authenticationManager) {
// return new HttpSessionListener() {
// @Override
// public void sessionCreated(HttpSessionEvent se) {
// String username = "admin";
// LoggerFactory.getLogger(this.getClass()).info("Session created {}. Authenticated, as {}", se.getSession().getId(), username);
// SecurityContext context = SecurityContextHolder.createEmptyContext();
// UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, "1");
// Authentication authenticated = authenticationManager.authenticate(authentication);
// context.setAuthentication(authenticated);
// SecurityContextHolder.setContext(context);
// se.getSession().setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
// }
// };
// }
// todo: remove when login/logout is implemented, since we do not need automatically created session with no authentication
private void configureSessionManagement(SessionManagementConfigurer<HttpSecurity> sessionManagement) {
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.ALWAYS);
}
private void configureHttpAuthorization(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry httpAuthorization) {
/* API ROUTES */
httpAuthorization.requestMatchers("/api/v1/user/logout").authenticated();

View File

@ -3,10 +3,10 @@ package ru.tubryansk.tdms.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import ru.tubryansk.tdms.dto.LoginDTO;
import ru.tubryansk.tdms.dto.UserDTO;
import ru.tubryansk.tdms.service.AuthenticationService;
import ru.tubryansk.tdms.service.CallerService;
import ru.tubryansk.tdms.service.UserService;
@RestController
@RequestMapping("/api/v1/user")
@ -28,7 +28,7 @@ public class UserController {
}
@PostMapping("/login")
public void login(@RequestParam String username, @RequestParam String password) {
authenticationService.login(username, password);
public void login(@RequestBody LoginDTO loginDTO) {
authenticationService.login(loginDTO.username(), loginDTO.password());
}
}

View File

@ -0,0 +1,6 @@
package ru.tubryansk.tdms.dto;
public record LoginDTO(
String username,
String password) {
}

View File

@ -15,7 +15,7 @@ import org.springframework.security.core.GrantedAuthority;
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "role")
@Table(name = "`role`")
public class Role implements GrantedAuthority {
@Id
@Column(name = "id")

View File

@ -20,7 +20,7 @@ import java.util.List;
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "user")
@Table(name = "`user`")
public class User implements UserDetails {
@Id
@Column(name = "id")

View File

@ -2,6 +2,7 @@ package ru.tubryansk.tdms.service;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@ -12,6 +13,7 @@ import ru.tubryansk.tdms.entity.User;
import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;
@Service
@Slf4j
public class AuthenticationService {
@Autowired
private HttpServletRequest request;
@ -31,10 +33,17 @@ public class AuthenticationService {
}
public void login(String username, String password) {
var context = SecurityContextHolder.createEmptyContext();
var token = new UsernamePasswordAuthenticationToken(username, password);
var authenticated = authenticationManager.authenticate(token);
context.setAuthentication(authenticated);
request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
try {
var context = SecurityContextHolder.createEmptyContext();
var token = new UsernamePasswordAuthenticationToken(username, password);
var authenticated = authenticationManager.authenticate(token);
context.setAuthentication(authenticated);
SecurityContextHolder.setContext(context);
request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
} catch (Exception e) {
log.error("Failed to log in user: {}. {}", username, e.getMessage());
return;
}
log.debug("User {} logged in", username);
}
}

View File

@ -0,0 +1,27 @@
package ru.tubryansk.tdms.web;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.AbstractRequestLoggingFilter;
@Component
@Slf4j
public class RequestLogger extends AbstractRequestLoggingFilter {
@Override
protected void beforeRequest(HttpServletRequest request, String message) {
String logMessage = message +
", method=" + request.getMethod() +
", uri=" + request.getRequestURI() +
", query=" + request.getQueryString() +
", remote=" + request.getRemoteAddr() +
", user=" + request.getRemoteUser();
log.debug(logMessage);
}
@Override
protected void afterRequest(HttpServletRequest request, String message) {
// do nothing
}
}

View File

@ -18,7 +18,7 @@ class DefaultPage extends Component<any> {
static contextType = RootStoreContext;
render() {
let isLoading = this.context.userStore.isLoading;
let isLoading = this.context.pendingStore.isThinking();
return <>
<Header/>

View File

@ -2,60 +2,99 @@ import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
import {Component} from "react";
import {RouterLink} from "mobx-state-router";
import {IAuthenticated} from "../../../models/user";
import {makeObservable} from "mobx";
import {RootStoreContext, RootStoreContextType} from "../../../context/RootStoreContext";
import {observer} from "mobx-react";
import {post} from "../../../utils/request";
import {LoginModal} from "../../user/LoginModal";
import {ModalState} from "../../../state/ModalState";
import {action, makeObservable} from "mobx";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
@observer
class Header extends Component {
declare context: RootStoreContextType;
static contextType = RootStoreContext;
loginModalState = new ModalState();
constructor(props: any) {
super(props);
makeObservable(this);
}
get loginThink() {
return this.context.pendingStore.isThinking('updateCurrentUser');
}
render() {
const userStore = this.context.userStore;
const user = userStore.user;
return <>
<header>
<Navbar className="bg-body-tertiary" fixed="top">
<Container>
<Navbar.Brand>
<Nav.Link as={RouterLink} routeName='root'>TDMS</Nav.Link>
</Navbar.Brand>
<Nav>
<NavDropdown title="Группы">
<NavDropdown.Item>Список</NavDropdown.Item>
<NavDropdown.Item>Редактировать</NavDropdown.Item>
</NavDropdown>
</Nav>
<Nav className="ms-auto">
{
this.loginThink &&
<FontAwesomeIcon icon='gear' spin/>
}
{
user.authenticated && !this.loginThink &&
<AuthenticatedItems/>
}
{
!user.authenticated && !this.loginThink &&
<>
<Nav.Link onClick={this.loginModalState.open}>Войти</Nav.Link>
</>
}
</Nav>
</Container>
</Navbar>
</header>
<LoginModal modalState={this.loginModalState}/>
</>
}
}
class AuthenticatedItems extends Component {
declare context: RootStoreContextType;
static contextType = RootStoreContext;
constructor(props: any) {
super(props);
makeObservable(this);
}
@action.bound
logout() {
post('user/logout').then(() => this.context.userStore.updateCurrentUser());
}
render() {
const userStore = this.context.userStore;
const routerStore = this.context.routerStore;
const user = userStore.user;
return <header>
<Navbar className="bg-body-tertiary" fixed="top">
<Container>
<Navbar.Brand>
<Nav.Link as={RouterLink} routeName='root'>TDMS</Nav.Link>
</Navbar.Brand>
<Nav>
<NavDropdown title="Группы">
<NavDropdown.Item>Список</NavDropdown.Item>
<NavDropdown.Item>Редактировать</NavDropdown.Item>
</NavDropdown>
</Nav>
<Nav className="ms-auto">
{
user.authenticated &&
<>
<Navbar.Text>Пользователь:</Navbar.Text>
<NavDropdown
title={(user as IAuthenticated).fullName}>
<NavDropdown.Item onClick={() => {routerStore.goTo('profile')}}>Моя страница</NavDropdown.Item>
<NavDropdown.Divider/>
<NavDropdown.Item>Выйти</NavDropdown.Item>
</NavDropdown>
</>
}
{
!user.authenticated &&
<Nav.Link as={RouterLink} routeName='login'>Войти</Nav.Link>
}
</Nav>
</Container>
</Navbar>
</header>
return <>
<Navbar.Text>Пользователь:</Navbar.Text>
<NavDropdown
title={(user as IAuthenticated).fullName}>
<NavDropdown.Item as={RouterLink} routeName='profile'>Моя страница</NavDropdown.Item>
<NavDropdown.Divider/>
<NavDropdown.Item onClick={this.logout}>Выйти</NavDropdown.Item>
</NavDropdown>
</>
}
}

View File

@ -0,0 +1,121 @@
import {ChangeEvent, Component} from "react";
import {Button, FormControl, FormGroup, FormLabel, FormText, Modal} from "react-bootstrap";
import {ModalState} from "../../state/ModalState";
import {observer} from "mobx-react";
import {action, computed, makeObservable, observable, reaction} from "mobx";
import {post} from "../../utils/request";
import {RootStoreContext, RootStoreContextType} from "../../context/RootStoreContext";
interface LoginModalProps {
modalState: ModalState;
}
@observer
export class LoginModal extends Component<LoginModalProps> {
declare context: RootStoreContextType;
static contextType = RootStoreContext;
modalState = this.props.modalState;
@observable login = '';
@observable loginError = '';
@observable password = '';
@observable passwordError = '';
@observable rememberMe = false;
constructor(props: LoginModalProps) {
super(props);
makeObservable(this);
reaction(() => this.login, this.validateLogin);
reaction(() => this.password, this.validatePassword);
}
@action.bound
validateLogin() {
if (!this.login) {
this.loginError = 'Имя пользователя не может быть пустым';
} else if (this.login.length < 5) {
this.loginError = 'Имя пользователя должно быть не менее 5 символов';
} else if (this.login.length > 50) {
this.loginError = 'Имя пользователя должно быть не более 50 символов';
} else if (!/^[a-zA-Z0-9_]+$/.test(this.login)) {
this.loginError = 'Имя пользователя должно содержать только латинские буквы, цифры и знак подчеркивания';
} else {
this.loginError = '';
}
}
@action.bound
validatePassword() {
if (!this.password) {
this.passwordError = 'Пароль не может быть пустым';
} else if (this.password.length < 5) {
this.passwordError = 'Пароль должен быть не менее 5 символов';
} else if (this.password.length > 32) {
this.passwordError = 'Пароль должен быть не более 32 символов';
} else if (!/^[a-zA-Z0-9!@#$%^&*()_+]+$/.test(this.password)) {
this.passwordError = 'Пароль должен содержать только латинские буквы, цифры и специальные символы';
} else {
this.passwordError = '';
}
}
@computed
get loginButtonDisabled() {
return !this.login || !this.password || !!this.loginError || !!this.passwordError;
}
@action.bound
onLoginInput(event: ChangeEvent<HTMLInputElement>) {
this.login = event.target.value;
}
@action.bound
onPasswordInput(event: ChangeEvent<HTMLInputElement>) {
this.password = event.target.value;
}
@action.bound
onLogin() {
if (this.loginButtonDisabled)
return;
post('user/login', {
username: this.login,
password: this.password
}).then(() => {
this.context.userStore.updateCurrentUser();
this.modalState.close();
});
}
render() {
return <Modal show={this.modalState.isOpen} centered>
<Modal.Header>
<Modal.Title>Вход</Modal.Title>
</Modal.Header>
<Modal.Body>
<FormGroup className={'mb-3'}>
<FormLabel>Имя пользователя</FormLabel>
<FormControl type="text" onChange={this.onLoginInput}/>
{
this.loginError &&
<FormText className={'text-danger'}>{this.loginError}</FormText>
}
</FormGroup>
<FormGroup className={'mb-3'}>
<FormLabel>Пароль</FormLabel>
<FormControl type="password" onChange={this.onPasswordInput}/>
{
this.passwordError &&
<FormText className={'text-danger'}>{this.passwordError}</FormText>
}
</FormGroup>
</Modal.Body>
<Modal.Footer>
<Button variant={'primary'} onClick={this.onLogin} disabled={this.loginButtonDisabled}>Войти</Button>
<Button variant={'secondary'} onClick={this.modalState.close}>Закрыть</Button>
</Modal.Footer>
</Modal>
}
}

View File

@ -1,4 +1,4 @@
import {DefaultPage} from "./layout/DefaultPage";
import {DefaultPage} from "../page/layout/DefaultPage";
import {Col, Form, Row} from "react-bootstrap";
import {observer} from "mobx-react";
import {RootStoreContext, type RootStoreContextType} from "../../context/RootStoreContext";
@ -151,7 +151,7 @@ class StudentInfo extends Component<{student: IStudent}> {
}
}
export default class UserProfile extends DefaultPage {
export default class UserProfilePage extends DefaultPage {
declare context: RootStoreContextType;
static contextType = RootStoreContext;

View File

@ -1,3 +1,4 @@
// todo: update
export enum Role {
STUDENT = 'ROLE_STUDENT',
TUTOR = 'ROLE_TUTOR',

View File

@ -1,10 +1,10 @@
import {ViewMap} from "mobx-state-router";
import Home from "../components/page/Home";
import Error from "../components/page/Error";
import UserProfile from "../components/page/UserProfile";
import UserProfilePage from "../components/user/UserProfilePage";
export const viewMap: ViewMap = {
root: <Home/>,
profile: <UserProfile/>,
profile: <UserProfilePage/>,
error: <Error/>,
}

View File

@ -0,0 +1,20 @@
import {action, makeObservable, observable} from "mobx";
export class ModalState {
@observable isOpen: boolean;
constructor(isOpen: boolean = false) {
makeObservable(this);
this.isOpen = isOpen;
}
@action.bound
open = () => {
this.isOpen = true;
};
@action.bound
close = () => {
this.isOpen = false;
};
}

View File

@ -0,0 +1,41 @@
import {action, makeObservable, observable} from "mobx";
import {RootStore} from "./RootStore";
export class PendingStore {
rootStore: RootStore;
@observable pendingMap = new Map<string, number>();
constructor(rootStore: RootStore) {
this.rootStore = rootStore;
makeObservable(this);
}
@action.bound
think(key: string = '$default') {
const count = this.pendingMap.get(key) || 0;
this.pendingMap.set(key, count + 1);
}
@action.bound
completeOne(key: string) {
const count = this.pendingMap.get(key);
if (count === undefined) {
return;
}
this.pendingMap.set(key, count - 1);
if (this.pendingMap.get(key) === 0) {
this.pendingMap.delete(key);
}
}
@action.bound
completeAll(key: string) {
this.pendingMap.delete(key);
}
@action.bound
isThinking(key: string = '$default') {
return this.pendingMap.get(key) !== undefined;
}
}

View File

@ -1,7 +1,9 @@
import {MyRouterStore} from "./MyRouterStore";
import {UserStore} from "./UserStore";
import {PendingStore} from "./PendingStore";
export class RootStore {
pendingStore = new PendingStore(this);
userStore = new UserStore(this);
routerStore = new MyRouterStore(this);

View File

@ -1,5 +1,5 @@
import {get} from "../utils/request";
import {makeObservable, observable, runInAction} from "mobx";
import {action, makeObservable, observable, runInAction} from "mobx";
import {RootStore} from "./RootStore";
import type {IUser} from "../models/user";
import {IStudent} from "../models/student";
@ -7,25 +7,23 @@ import {Role} from "../models/role";
export class UserStore {
rootStore: RootStore;
@observable
user: IUser = {authenticated: false};
@observable
student: IStudent | undefined;
@observable
isLoading: boolean = true;
@observable user: IUser = {authenticated: false};
@observable student?: IStudent;
constructor(rootStore: RootStore) {
makeObservable(this);
this.rootStore = rootStore;
}
fetchCurrentUserData() {
@action.bound
updateCurrentUser() {
// todo: store token in localStorage
this.rootStore.pendingStore.think('updateCurrentUser');
get<IUser>('/user/current').then((response) => {
runInAction(() => {
this.user = response;
});
if(response.authenticated && response.authorities.some(a => a.authority === Role.STUDENT)) {
if (response.authenticated && response.authorities.some(a => a.authority === Role.STUDENT)) {
get<IStudent>('/student/current').then((student) => {
runInAction(() => {
this.student = student;
@ -34,13 +32,13 @@ export class UserStore {
}
}).finally(() => {
runInAction(() => {
this.isLoading = false;
this.rootStore.pendingStore.completeOne('updateCurrentUser');
});
});
}
init() {
this.fetchCurrentUserData();
this.updateCurrentUser();
return this;
}
}

View File

@ -21,7 +21,7 @@ export const post = async <T,> (url: string, data?: any) => {
export const request = async <T,> (config: AxiosRequestConfig<any>) => {
return new Promise<T>((resolve, reject) => {
console.debug(`${config.method} ${config.url} request: ${config.method === 'GET' ? JSON.stringify(config.params) : JSON.stringify(config.data)}`);
axios.request({...config, baseURL: apiUrl}).then((response) => {
axios.request({...config, baseURL: apiUrl, withCredentials: true}).then((response) => {
console.debug(`${config.method} ${config.url} response: ${JSON.stringify(response.data)}`);
resolve(response.data);
}).catch((error) => {