Chats 프로젝트 (7) - OAuth2.0, Login
로그인 화면을 메인화면으로 변경하고 로그인 버튼에 API 연동을 진행 해보겠습니다.
로그인 화면을 메인으로 진행하는건 index.html 파일을 해당 로그인에 사용하던 페이지로 변경하면 됩니다.
login.js
document.querySelector('.login-form').addEventListener('submit', function(event) {
event.preventDefault(); // 폼의 기본 제출 동작 방지
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const loginData = {
email: username,
password: password
};
fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(loginData)
})
.then(response => response.json())
.then(data => {
if (data.status === "success") {
// 로그인 성공 시, 액세스 토큰을 로컬 스토리지에 저장
localStorage.setItem('accessToken', data.data);
console.log('로그인에 성공했습니다.');
window.location.href = "/html/chats.html";
} else {
// 실패 메시지 처리
console.log('로그인에 실패했습니다.');
}
})
.catch((error) => {
// 오류 처리
console.error('Error:', error);
});
});
간단하게 로그인이 성공하면 localStorage에 토큰 값을 저장하도록 했습니다.
그리고 채팅 화면으로 넘어가도록 했습니다. 이제 필요한 것은 소셜 로그인과 로그아웃 버튼
그리고 채팅을 위한 준비 및 구현이 남은 것 같습니다.
그럼 먼저 소셜 로그인을 구현하도록 하겠습니다.
소셜로그인은 처음 로그인을 하면 필요한 추가 정보를 입력 받도록 해야합니다.
저는 소셜 로그인을 하게되면 간단하게 이메일만 받아올거기 때문에 나머지는 추가 필드를 생성해서 API를 만드는 것으로 하겠습니다.
일단 추가 필드는 회원 가입에서 사용한 것을 변형해서 사용하도록 하겠습니다.
그럼 API 먼저 생성 하도록 하겠습니다.
private String provider;
public static User createSocialUser(String nickname, String email, String phoneNumber, String provider) {
return User.builder()
.nickname(nickname)
.email(email)
.phoneNumber(phoneNumber)
.role(Role.USER)
.provider(provider)
.build();
}
provider를 User에 추가를 해주고 createSocialUser도 만들어줍니다.
추후에 사용될 것 같아서 미리 만들어줬습니다.
OAuth2Attribute.java
@Getter
public class OAuth2Attribute {
private final Map<String, Object> attributes; // 사용자 속성 정보를 담는 Map
private final String attributeKey; // 사용자 속성의 키 값
private final String email; // 이메일 정보
private final String name; // 이름 정보
private final String picture; // 프로필 사진 정보
private final String provider; // 제공자 정보
@Builder
private OAuth2Attribute(Map<String, Object> attributes, String attributeKey, String email, String name, String picture, String provider) {
this.attributes = attributes;
this.attributeKey = attributeKey;
this.email = email;
this.name = name;
this.picture = picture;
this.provider = provider;
}
// 서비스에 따라 OAuth2Attribute 객체를 생성하는 메서드
static OAuth2Attribute of(String provider, String attributeKey,
Map<String, Object> attributes) {
return switch (provider) {
case "google" -> ofGoogle(provider, attributeKey, attributes);
case "kakao" -> ofKakao(provider, "email", attributes);
case "naver" -> ofNaver(provider, "id", attributes);
default -> throw new NotFoundException(ErrorMessage.OAUTH_PROVIDER_NOT_FOUND);
};
}
/*
* Google 로그인일 경우 사용하는 메서드, 사용자 정보가 따로 Wrapping 되지 않고 제공되어,
* 바로 get() 메서드로 접근이 가능하다.
* */
private static OAuth2Attribute ofGoogle(String provider, String attributeKey, Map<String, Object> attributes) {
return OAuth2Attribute.builder()
.email((String) attributes.get("email"))
.provider(provider)
// .name((String)attributes.get("name"))
// .picture((String)attributes.get("picture"))
.attributes(attributes)
.attributeKey(attributeKey)
.build();
}
/*
* Kakao 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 kakaoAccount -> kakaoProfile 두번 감싸져 있어서,
* 두번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다.
* */
private static OAuth2Attribute ofKakao(String provider, String attributeKey, Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
return OAuth2Attribute.builder()
.email((String) kakaoAccount.get("email"))
.provider(provider)
// .name((String) kakaoProfile.get("nickname"))
// .picture((String) kakaoProfile.get("profile_image_url"))
.attributes(kakaoAccount)
.attributeKey(attributeKey)
.build();
}
/*
* Naver 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 response Map에 감싸져 있어서,
* 한번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다.
* */
private static OAuth2Attribute ofNaver(String provider, String attributeKey, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuth2Attribute.builder()
.email((String) response.get("email"))
.provider(provider)
// .name((String) response.get("name"))
// .picture((String) response.get("profile_image"))
.attributes(response)
.attributeKey(attributeKey)
.build();
}
// OAuth2User 객체에 넣어주기 위해서 Map으로 값들을 반환해준다.
Map<String, Object> convertToMap() {
Map<String, Object> map = new HashMap<>();
map.put("id", attributeKey);
map.put("key", attributeKey);
map.put("email", email);
map.put("provider", provider);
return map;
}
}
를 먼저 만들어줍니다.
OAuth2UserService를 상속받은 CustomOAuth2UserService를 만들어줍니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 기본 OAuth2UserService 객체 생성
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService =
new DefaultOAuth2UserService();
// OAuth2UserService를 사용하여 OAuth2User 정보를 가져온다.
OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
// 클라이언트 등록 ID(google, kakao, naver) 와 사용자 속성을 가져온다.
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
// OAuth2UserService를 사용하여 가져온 OAuth2User 정보로 OAuth2Attribute 객체를 만든다.
OAuth2Attribute oAuth2Attribute =
OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
// OAuth2Attribute의 속성 값들을 Map으로 반환 받는다.
Map<String, Object> userAttribute = oAuth2Attribute.convertToMap();
// 사용자 email 정보를 가져온다.
String email = (String) userAttribute.get("email");
// 이메일로 가입된 회원인지 조회한다.
Optional<User> findUser = userRepository.findByEmail(email);
if (findUser.isEmpty()) {
// 회원이 없는 경우
userAttribute.put("exist", false);
// 회원의 권한(회원이 존재하지 않으므로 기본권한인 ROLE_USER를 넣어준다), 회원속성, 속성이름을 이용해 DefaultOAuth2User 객체를 생성해 반환한다.
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(Role.USER.getDescription())
), userAttribute, "email");
}
// 회원이 존재
userAttribute.put("exist", true);
// 회원의 권한과, 회원속성, 속성이름을 이용해 DefaultOAuth2User 객체를 생성해 반환한다.
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(findUser.get().getRoleDescription())
), userAttribute, "email");
}
}
SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final MyAuthenticationSuccessHandler oAuth2LoginSuccessHandler;
private final CustomOAuth2UserService customOAuth2UserService;
private final JwtAuthFilter jwtAuthFilter;
private final JwtExceptionFilter jwtExceptionFilter;
private final MyAuthenticationFailureHandler oAuth2LoginFailureHandler;
private final MyAccessDeniedHandler accessDeniedHandler;
private final MyAuthenticationEntryPoint authenticationEntryPoint;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/css/**", "/images/**", "/js/**").permitAll()
.requestMatchers("**").permitAll())
.formLogin(AbstractHttpConfigurer::disable)
.exceptionHandling(exceptionHandling ->
exceptionHandling.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint))
.oauth2Login((oauth2) -> oauth2.userInfoEndpoint(
userInfoEndpoint ->userInfoEndpoint.userService(customOAuth2UserService))
.failureHandler(oAuth2LoginFailureHandler)
.successHandler(oAuth2LoginSuccessHandler));
return http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class)
.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
시큐리티 설정 파일은 다음과 같이 변경 해주시면 됩니다.
소셜 로그인이 성공을 했을 때와 실패를 했을 때 handler를 생성을 합니다.
MyAuthenticationSuccessHandler.java
@Slf4j
@Component
@RequiredArgsConstructor
public class MyAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
private final RefreshTokenService refreshTokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// OAuth2User로 캐스팅하여 인증된 사용자 정보를 가져온다.
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
// 사용자 이메일을 가져온다
String email = oAuth2User.getAttribute("email");
// 서비스 제공 플랫폼(GOOGLE, KAKAO, NAVER)이 어디인지 가져온다.
String provider = oAuth2User.getAttribute("provider");
// CustomOAuth2UserService에서 셋팅한 로그인 회원 존재 여부를 가져온다.
boolean isExist = oAuth2User.getAttribute("exist");
// OAuth2User로 부터 Role을 얻어온다.
String role = oAuth2User.getAuthorities().stream()
.findFirst()
.orElseThrow(IllegalAccessError::new) // 존재하지 않으면 예외 반환
.getAuthority();
// 회원이 존재를 하면
if (isExist) {
// jwt 토큰 발행
GeneratedTokenDto token = jwtUtil.generatedToken(email, role);
log.info("jwtToken = {}", token.getAccessToken());
log.debug("redis 토큰 저장");
refreshTokenService.saveTokenInfo(
email,
token.getAccessToken(),
token.getRefreshToken()
);
// accessToken을 쿼리스트링에 담는 url 생성
String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:8080/loding")
.queryParam("accessToken", token.getAccessToken())
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
log.info("redirect 준비");
// 로그인 확인 페이지로 리다이렉트 시킨다.
getRedirectStrategy().sendRedirect(request, response, targetUrl);
} else {
// 회원이 존재하지 않을경우, 서비스 제공자와 email을 쿼리스트링으로 전달하는 url을 만들어준다.
String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:8080/html/addInfo.html")
.queryParam("email", (String) oAuth2User.getAttribute("email"))
.queryParam("provider", provider)
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
// 회원가입 페이지로 리다이렉트 시킨다.
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
}
MyAuthenticationSuccessHandler.java 는 SimpleUrlAuthenticationSuccessHandler를 상속받아
회원이 존재를 할 때와 존재하지 않을 때 리디렉션을 정해주고 리디렉션을 할 때 정보를 넘겨줍니다.
회원이 존재한다면 쿼리 스트링으로 액세스 토큰을 넣어주는데 해당 액세스 토큰 값을 받을 수 있는 로딩 페이지를 만들어서
값을 받고 채팅 페이지로 넘어가도록 만들려고 합니다.
존재 하지 않는다면 추가 정보를 입력하는 페이지를 만들려고 합니다.
MyAuthenticationFailureHandler.java
@Slf4j
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
// 인증 실패시 메인 페이지로 이동
response.sendRedirect("http://localhost:8080");
}
}
실패를 하면 다시 로그인 화면으로 돌아가도록 만들었습니다.
이제 소셜로그인의 회원 가입을 만들어보도록 하겠습니다.
SocialLoginRequestDto.java
@Getter
public class SocialLoginRequestDto {
private String nickname;
private String email;
private String phoneNumber;
private String provider;
@Builder
public SocialLoginRequestDto(String nickname, String email, String password, String phoneNumber, String provider) {
this.nickname = nickname;
this.email = email;
this.password = password;
this.phoneNumber = phoneNumber;
this.provider = provider;
}
}
RequestDto를 먼저 생성하도록 하겠습니다.
SignupService.java
@Transactional
public Long socialLogin(SocialLoginRequestDto socialLoginRequestDto) {
User user = User.createSocialUser(
socialLoginRequestDto.getNickname(), socialLoginRequestDto.getEmail(),
socialLoginRequestDto.getPhoneNumber(), socialLoginRequestDto.getProvider());
User userSave = userRepository.save(user);
log.debug("소셜 로그인 - 회원가입 성공");
return userSave.getId();
}
기존 회원가입 서비스에 추가를 해주도록 하겠습니다.
컨트롤러도 마찬가지로 기존에 사용하던 컨트롤러에 추가를 해주도록 하겠습니다.
SignupController.java
@PostMapping("/signup/social")
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<Object> socialSignup(@RequestBody SocialLoginRequestDto socialLoginRequestDto) {
log.debug("소셜 로그인 - 회원가입 API 호출");
Long userId = signupService.socialLogin(socialLoginRequestDto);
CommonResponseDto<Object> commonResponseDto =
commonService.successResponse(SOCIAL_LOGIN_SIGNUP_SUCCESS.getDescription(), userId);
return ResponseEntity.status(HttpStatus.CREATED).body(commonResponseDto);
}
다음과 같이 API를 생성 하였습니다.
이제 화면 구성을 만들어보도록 하겠습니다.
addInfo.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ADD INFO - Chats</title>
<link rel="stylesheet" href="/css/signupPage.css">
</head>
<body>
<div class="signup-container">
<h1 class="signup-title">추가 정보</h1>
<form class="signup-form" id="signupForm">
<!-- 닉네임 입력 필드와 중복 확인 버튼 -->
<div class="form-row">
<input type="text" id="nickname" name="nickname" placeholder="닉네임" required>
<button type="button" class="check-button nickname">중복 확인</button>
</div>
<!-- 이메일 입력 필드와 중복 확인 버튼 -->
<div class="form-row">
<input type="email" id="email" name="email" placeholder="이메일" required>
</div>
<!-- 전화번호 입력 필드 -->
<div class="form-row">
<input type="tel" id="phone" name="phoneNumber" placeholder="전화번호" required
maxlength="11" minlength="11" title="11자리 숫자를 입력해주세요.">
</div>
<!-- 회원가입 버튼 -->
<div class="form-row">
<button type="submit" id="signupButton" class="signup-button">회원가입</button>
</div>
</form>
</div>
<script src="/js/addInfo.js"></script>
</body>
</html>
추가 정보를 입력을 받을 수 있는 필드를 생성을 했습니다.
생성한 회원 가입 정보를 회원 가입 버튼에 연결하고 닉네임 중복 확인은 자체 로그인 회원 가입에서 사용하던 그대로 가져와
사용하겠습니다.
addInfo.js
document.addEventListener('DOMContentLoaded', function () {
const queryParams = new URLSearchParams(window.location.search);
const email = queryParams.get('email');
if (email) {
document.getElementById('email').value = email;
}
});
document.getElementById('signupForm').addEventListener('submit', function(event) {
event.preventDefault(); // 폼의 기본 제출 동작 방지
const nickname = document.getElementById('nickname').value;
const email = document.getElementById('email').value;
const phone = document.getElementById('phone').value;
// 쿼리 스트링에서 provider 값을 추출
const queryParams = new URLSearchParams(window.location.search);
const provider = queryParams.get('provider');
const signupData = {
nickname: nickname,
email: email,
phoneNumber: phone,
provider: provider // provider 값을 포함
};
fetch('/signup/social', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(signupData)
})
.then(response => response.json())
.then(data => {
console.log('회원가입 성공:', data);
window.location.href = "/";
})
.catch((error) => {
console.error('회원가입 실패:', error);
// 오류 처리
});
});
document.addEventListener('DOMContentLoaded', function () {
// 쿼리 스트링에서 이메일과 프로바이더 추출
const queryParams = new URLSearchParams(window.location.search);
const email = queryParams.get('email');
const provider = queryParams.get('provider');
// 이메일 필드에 이메일 설정
if (email) {
document.getElementById('email').value = email;
}
let isNicknameAvailable = false;
let isEmailAvailable = false; // 이메일 중복 확인 로직이 있을 경우 사용
const signupButton = document.getElementById('signupButton');
const signupForm = document.getElementById('signupForm');
const nicknameCheckButton = document.querySelector('.check-button.nickname');
// 초기에는 회원가입 버튼을 비활성화합니다.
signupButton.disabled = true;
// 닉네임 중복 검사
nicknameCheckButton.addEventListener('click', function () {
const nickname = document.getElementById('nickname').value;
if (nickname.trim() === '') { // 빈 문자열 검사
alert('닉네임을 입력해주세요.');
return;
}
checkDuplicate('nickname', nickname, function (isAvailable) {
isNicknameAvailable = isAvailable;
toggleSignupButton(); // 중복 검사 후 회원가입 버튼 활성화 상태 업데이트
});
});
// 회원가입 요청 함수
signupForm.addEventListener('submit', function(event) {
event.preventDefault(); // 폼의 기본 제출 동작 방지
if (isNicknameAvailable && isEmailAvailable) {
const nickname = document.getElementById('nickname').value;
const phone = document.getElementById('phone').value;
const signupData = {
nickname: nickname,
email: email,
phoneNumber: phone,
provider: provider
};
fetch('/signup/social', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(signupData)
})
.then(response => response.json())
.then(data => {
console.log('회원가입 성공:', data);
window.location.href = "/";
})
.catch((error) => {
console.error('회원가입 실패:', error);
});
}
});
// 중복 검사 함수
function checkDuplicate(type, value, callback) {
const queryParams = new URLSearchParams({[type]: value});
fetch(`http://localhost:8080/check/${type}?${queryParams}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => {
if (response.status === 409) { // 오류 코드 409 처리
return response.json().then(data => {
throw new Error(data.message); // 오류 메시지를 throw
});
}
if (!response.ok) { // 기타 오류 처리
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
alert(data.message); // 성공 시 메시지 표시
callback(data.status === 'success');
})
.catch(error => {
console.error('Error:', error);
alert(error.message); // 오류 메시지 표시
callback(false);
});
}
// 회원가입 버튼 활성화 상태를 결정하는 함수
function toggleSignupButton() {
signupButton.disabled = !(isNicknameAvailable);
}
});
그리고 회원 가입을 하지 않고 바로 로그인 진행을 위해 로딩 페이지를 만들어서 토큰 값을 로컬 스토리지에
저장을 하게 되면 채팅 페이지로 넘어가도록 설정을 해보도록 하겠습니다.
빈 html 파일을 loading.html 만듭니다.
그 후에 js만 적용을 해주도록 하겠습니다.
document.addEventListener('DOMContentLoaded', function () {
// 쿼리 스트링에서 accessToken 추출
const queryParams = new URLSearchParams(window.location.search);
const accessToken = queryParams.get('accessToken');
if (accessToken) {
// 로컬 스토리지에 액세스 토큰 저장
localStorage.setItem('accessToken', accessToken);
// 저장 후 다른 페이지로 리디렉션
window.location.href = '/html/chats.html'; // 원하는 리디렉션 대상 URL로 변경
}
});
소셜 로그인을 진행을 했을 때 쿼리 스트링으로 값을 넘겨주니 그 값을 가져오기 위해
URLSearchParams를 사용합니다.
해당 값을 가져온 뒤 로컬 스토리지에 저장을 하고 리디렉션을 진행을 하도록 합니다.
그리고 다음과 같이 버튼을 변경을 합니다.
index.html
<div class="social-login">
<a href="/oauth2/authorization/kakao" class="social-button kakao">카카오로 로그인</a>
<a href="/oauth2/authorization/google" class="social-button google">구글로 로그인</a>
<a href="/oauth2/authorization/naver" class="social-button naver">네이버로 로그인</a>
</div>
loginPage.css
.social-login {
display: flex; /* Flexbox 레이아웃 사용 */
flex-wrap: wrap; /* 버튼이 여러 줄로 나누어지도록 설정 */
justify-content: center; /* 중앙 정렬 */
gap: 10px; /* 버튼 사이의 간격 */
margin-top: 20px; /* 상단 여백 추가 */
}
.social-button {
background-color: #FEE500; /* 카카오 색상 */
border: none;
border-radius: 4px;
padding: 10px;
width: 100%; /* 전체 너비 사용 */
cursor: pointer;
text-align: center; /* 텍스트 중앙 정렬 */
color: black; /* 텍스트 색상 */
text-decoration: none; /* 밑줄 제거 */
font-size: 14px; /* 폰트 크기 설정 */
}
.social-button.naver {
background-color: #03C75A; /* 네이버 색상 */
}
.social-button.google {
background-color: #f5f5f5; /* 구글 색상 */
}
/* 반응형 디자인을 위한 미디어 쿼리 */
@media (max-width: 600px) {
.social-button {
width: 100%; /* 모바일 화면에서는 버튼 너비를 100%로 설정 */
margin-bottom: 10px; /* 버튼 사이의 여백 추가 */
}
}
CSS는 원하는 방식으로 변경하여 구현을 하시면 됩니다.
이렇게 소셜 로그인까지 연동을 마쳤습니다.
다음엔 채팅을 위해 웹소켓 구현을 진행 하도록 하겠습니다.
Chats 프로젝트 (6) - JWT, Login, Redis
저번엔 서비스까지 작성을 하고 컨트롤러를 작성하지 않았는데 이번엔 컨트롤러를 작성하고 테스트까지 진행해보려고 합니다. 이후에 AccessToken과 RefreshToken을 Redis에 저장하고 관리하는 부분에
classruntime.tistory.com
GitHub - Llimy1/Chats
Contribute to Llimy1/Chats development by creating an account on GitHub.
github.com