IT 학원에서 사용하는 그룹웨어.
일반적인 그룹웨어와 동일하게 사원 생성, 로그인, 인사 관리, 근태 관리, 전자결재, 채팅 기능을 제공하고 별도로 강의 등록, 회의실 및 학생 상담실 예약 등의 기능을 제공한다.
자세한 소스 코드는 GitHub에서 확인 가능합니다!
이전 회사 생활을 하면서 사용했던 그룹웨어를 떠올렸을 때 일반적인 웹사이트와 다른 점이 있다면 계정을 사용자가 각자 생성해서 사용하지 않는다는 점이었다. 일반적인 웹사이트의 경우 사용자가 인증 과정을 거치면서 계정을 생성하고 아이디 중복 체크를 하지만 그룹웨어의 경우 인사 담당자가 계정을 만들면 입사날짜+임의 번호가 합쳐져서 “사번”이 부여된다.
“사번”과 “초기 비밀번호”는 인사 담당자(클라이언트)가 입력하는 부분은 아니고, 기본적인 개인정보(이름, 연락처, 부서, 직책)만 입력한다.
<insert id="insertEmployee" parameterType="map">
INSERT INTO MEMBER_TB
VALUES((TO_CHAR(TO_DATE(#{enrollDate},'YYYY-MM-DD'),'YYMMDD')||SEQ_MEMBER_ID.NEXTVAL)
,#{memberName},DEFAULT,#{password},#{memberNo},#{phone},#{mainAddress},NULL
,#{enrollDate},NULL,#{salary},DEFAULT,#{deptCode},#{jobCode},#{detailAddress})
</insert>
(TO_CHAR(TO_DATE(#{enrollDate},'YYYY-MM-DD'),'YYMMDD')||SEQ_MEMBER_ID.NEXTVAL)
로 입사년월일+시퀀스번호를 부여한다. 시퀀스는 1~99사이로 순회하도록 생성했다.
//저장된 아이디
var id=getCookie("rememberId");
$("#id").val(id);
if($("#id").val()!=""){
$("#remember-id").attr("checked",true);
}
//아이디 저장 체크박스
$("#remember-id").change(function(){
if($("#remember-id").is(":checked")){
setCookie("rememberId",$("#id").val(),7);
}else{
deleteCookie("rememberId");
}
});
//아이디 저장이 선택된 상태로 아이디 입력시
$("#id").keyup(function(){
if($("#remember-id").is(":checked")){
setCookie("rememberId",$("#id").val());
}
});
ready 함수 내부에 작성된 자바스크립트 코드는 위와 같다. 쿠키에 저장된 아이디가 있는 경우 바로 불러와서 input
태그에 값을 넣어주고 체크박스를 checked로 만들어줘야 하기 때문에 화면이 불러와졌을 때 한 번 실행되야 한다.
위 코드에 작성된 getCookie()
, setCookie()
, deleteCookie()
함수는 따로 선언한 함수다.
보통 JPA를 같이 사용하는 것 같지만... 이번 프로젝트에서 JPA를 사용하지 않았기 때문에 일반 객체(MemberVO 클래스)에 UserDetails를 상속 받아서 인증 객체로 이용했다.
@Override //권한 판단
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> auth=new ArrayList<>();
if(dept.getDeptCode().equals("D3")) auth.add(new SimpleGrantedAuthority(MemberAuthority.DEPT_EMP.name()));
if(job.getJobAuth().equals("MASTER")) auth.add(new SimpleGrantedAuthority(MemberAuthority.MASTER.name()));
if(job.getJobAuth().equals("SUBMASTER")||job.getJobAuth().equals("MASTER")) auth.add(new SimpleGrantedAuthority(MemberAuthority.SUBMASTER.name()));
if(job.getJobAuth().equals("TEAMMASTER")||job.getJobAuth().equals("MASTER")
||job.getJobAuth().equals("TEAMMASTER")) auth.add(new SimpleGrantedAuthority(MemberAuthority.TEAMMASTER.name()));
if(job.getJobAuth().equals("EMP")||job.getJobAuth().equals("MASTER")
||job.getJobAuth().equals("TEAMMASTER")||job.getJobAuth().equals("EMP")) {
auth.add(new SimpleGrantedAuthority(MemberAuthority.EMP.name()));
}
return auth;
}
@Override //퇴사 기간이 지났거나 입사 전이면 권한 없음
public boolean isCredentialsNonExpired() {
Date today=new Date();
return entDate==null||(today.before(entDate)&&today.after(hireDate));
}
위 코드는 MemberVO 클래스의 일부이다. UserDetails를 상속 받으면서 getAuthorities()
메소드 내부에 인증된 객체가 가지고 있을 권한들을 부여해주었다.
직책, 부서 별로 접근할 수 있는 페이지가 달라지기 때문에 권한에 따라 작성해주되, 대표가 그 아래 부대표, 팀장, 사원 등의 권한에도 사용할 수 있도록 가장 큰 권한부터 위에서 작성하여 하위 권한들을 상위 권한이 가질 수 있도록 코드를 작성했다.
추가로 그룹웨어이기 때문에 퇴사한 계정이나 입사일이 지정되어 있어 미리 계정은 만들어졌지만 입사 전인 계정은 권한을 받지 못하도록 추가적인 설정도 해주었다.
인증 객체에 대한 다른 설정은 이전 포스팅을 참고
@Configuration
@EnableWebSecurity
public class SecurityConfig{
@Autowired
private DBConnectProvider provider;
@Bean
public SecurityFilterChain authenticationPath(HttpSecurity http) throws Exception{
return http.csrf().disable()
.formLogin()
.successForwardUrl("/login/success")
.failureForwardUrl("/login/fail")
.passwordParameter("password")
.usernameParameter("memberId")
.loginProcessingUrl("/login")
.loginPage("/loginpage")
.and()
.authorizeHttpRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers("/error/**").permitAll()
.antMatchers("/loginpage").permitAll()
.antMatchers("/logout").permitAll()
.antMatchers("/resources/**").permitAll()
.antMatchers("/login/**").permitAll()
.antMatchers("/email/**").permitAll()
.antMatchers("/employee/job").hasAnyAuthority(MemberAuthority.SUBMASTER.name(), MemberAuthority.MASTER.name())
.antMatchers("/employee/enroll").hasAnyAuthority(MemberAuthority.TEAMMASTER.name(),
MemberAuthority.SUBMASTER.name(), MemberAuthority.MASTER.name())
.antMatchers("/employee/**").hasAnyAuthority(MemberAuthority.DEPT_EMP.name(), MemberAuthority.SUBMASTER.name(),
MemberAuthority.MASTER.name())
.antMatchers("/**").hasAuthority(MemberAuthority.EMP.name())
.and()
.logout()
.logoutSuccessUrl("/loginpage")
.logoutUrl("/logout")
.invalidateHttpSession(true)
.and()
.sessionManagement()
.invalidSessionUrl("/loginpage")
.and()
.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.sendRedirect(request.getContextPath()+"/error/login");
}
}).accessDeniedHandler(new AccessDeniedHandler() {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendRedirect(request.getContextPath()+"/error/auth");
}
})
.and()
.headers()
.frameOptions().sameOrigin()
.and()
.authenticationProvider(provider)
.build();
}
}
위 코드는 Security Configuration인데 마찬가지로 기본적인 설정 방법은 위 포스팅 링크를 참고하면 된다. 권한에 따라 접근할 부분을 나눠서 작성하고 가장 넓은(권한이 작은) 범위의 요청 주소를 마지막에 적어주면 된다. 작성한 순서대로 막아주기 때문!
그리고 exceptionHandling()
을 이용해서 권한 예외가 발생하는 포인트나 인증 예외가 발생하는 포인트에 대해서 처리를 해줬다. 로그인이 안 돼서 접근에 예외가 발생하는 경우나 권한이 없는데 요청을 보내려고 하면 그냥 에러 페이지가 뜨기 때문에 내가 만들어둔 요청 주소로 응답을 보내버렸다.
/error/login
으로 요청이 가면 “로그인 후 사용 가능합니다.” 라는 알림창을 띄워주고 로그인 페이지로 이동 시키고, /error/auth
로 요청이 가면 “권한이 없습니다.”라는 알림창을 띄워주고 메인 페이지로 이동시켜준다.
초기 비밀번호는 단순하게 설정되어있고 사원 생성 시 이메일을 입력받지 않았기 때문에 이메일 인증 기능과 비밀번호 변경을 함께 구현했다.
아래 화면은 이메일 인증이 완료되지 않은 사원이 로그인 시 메인 페이지가 아닌 인증 페이지로 전환시킨 화면이다. url을 수정해서 인증 처리를 건너뛰고 메인 페이지로 이동할 수 있는 것은 따로 막아두지 않았지만 재로그인 하면 결국 또 같은 화면으로 넘어가게 된다.
//email 인증
@PostMapping("/email")
@ResponseBody
public String sendEmail(@RequestParam(value="email") String email) {
String key="";
Random random=new Random();
SimpleMailMessage message=new SimpleMailMessage();
message.setTo(email);
for(int i=0;i<4;i++) {
int alpa=random.nextInt(25)+65; //A~Z 랜덤 알파벳 생성
key+=(char)alpa;
}
int number=random.nextInt(9999)+1000; //4자리 랜덤 숫자
key+=number;
message.setSubject("workit 이메일 인증 코드입니다."); //메일 제목
message.setText("인증 번호 : "+key);
javaMailSender.send(message);
return key;
}
이메일을 정규식 표현에 맞춰서 올바르게 작성 후 인증 버튼을 누르면 위 코드로 요청이 넘어가게 된다. javaMail API를 사용했는데 기본적인 설정 방법과 구글 이메일 2차 인증 방법 등은 아래 티스토리 링크를 참고했다.
로그인 화면에서 forgot your password? 를 클릭 시 비밀번호 재발급 화면으로 이동할 수 있게 된다.
스프링 시큐리티를 이용하고 있기 때문에 비밀번호 역시 단방향 암호화해서 관리하고 있어서 비밀번호를 잃어버렸다고 해도 기존 비밀번호를 찾을 수 없다.
때문에 영문자+숫자가 랜덤으로 조합된 임시 비밀번호를 이메일로 발급해주면서 해당 사용자의 비밀번호를 임시 비밀번호로 변경해주도록 만들었다.
//Cotroller
//비밀번호 재발급
@PutMapping("/email/password")
@ResponseBody()
public int passwordReissue(@RequestBody Map<String,Object> param) {
String key="";
Random random=new Random();
SimpleMailMessage message=new SimpleMailMessage();
message.setTo((String)param.get("email"));
for(int i=0;i<2;i++) {
key+=(char)((int)random.nextInt(25)+65); //A~Z 랜덤 알파벳 생성
key+=(int)random.nextInt(); //랜덤 숫자
key+=(char)((int)random.nextInt(25)+ 97); //a~z 랜덤 알파벳 생성
}
message.setSubject("workit 임시 비밀번호입니다."); //메일 제목
message.setText("임시 비밀번호 : "+key);
param.put("newPwd", key);
if(service.updateMember(param)>0) { //임시 비밀번호로 변경이 완료 되면 이메일 발송
javaMailSender.send(message);
return 1;
}
return 0;
}
--------------------------------------------------------------------------
//Service
@Override
public int updateMember(Map<String, Object> param) {
if(param.get("password")==null) { //임시 비밀번호 발급 시
param.put("password", dao.selectMemberByParam(param).getPassword()); //기존 비밀번호
}else {//비밀번호 변경 시
param.put("password", encoder.encode((String)param.get("password"))); //입력한 번호 암호화
}
param.put("newPwd", encoder.encode((String)param.get("newPwd"))); //신규 비밀번호 암호화
if(loadUserByUsername((String)param.get("memberId"))!=null) { //비밀 번호 변경 시 현재 비밀번호 일치하는 데이터가 있는지 확인
return dao.updateMember(param);
}else {
return -1; //없을 때 -1 반환
}
}
Service에서 updateMember()
메소드를 사원이 마이페이지에서 비밀번호를 변경하는 경우, 비밀번호 재발급하는 경우. 이 두 가지 경우에서 같이 사용 중이기 때문에 임시 번호 발급 시와 아닌 경우를 나눠서 넘길 값을 지정해줬다.
return -1을 하는 경우는 비밀번호 변경 시 현재 비밀번호와 일치하지 않으면 값을 사용자에게 현재 비밀번호가 일치하지 않다고 출력해주기 위해 구분 값으로 -1을 넘겼다.
프로필 이미지를 누르면 파일 선택이 가능하도록 하고 수정 버튼을 누르면 바로 파일 업로드가 되면서 데이터가 수정되도록 했다.
만약 파일이 선택되지 않은 상태로 수정 버튼 클릭 시 파일을 선택하라는 알림창이 한 번 뜬 후, 알아서 파일 선택창을 띄워준다.
비밀번호나 프로필 이미지는 사원이 자유롭게 수정할 수 있지만 그 외의 개인 정보는 인사팀에 수정 요청을 보내고 요청이 승인되면 변경되었으면 좋겠다는 의견이 있어서 그렇게 구현하기로 했던 기능이다.
변경 전 데이터를 input
값에 넣어준 상태로 화면을 불러오고 사원이 데이터를 전부 입력하면 사원 정보 요청 테이블에 데이터를 따로 저장해둔다. 그리고 인사팀에서 승인을 하면 저장된 데이터를 불러와서 사원 테이블의 해당 데이터를 수정하도록 했다.
수정 대기 현황이 존재하면 더 이상 추가 요청을 보낼 수 없도록 막아뒀다.
생각보다 글이 길어져서 인사관리 부분은 따로 나눠서 포스팅하겠습니다!ㅎㅎ