1.0.81 • Published 3 years ago

@psyrenpark/auth v1.0.81

Weekly downloads
3
License
MIT
Repository
github
Last release
3 years ago

@psyrenpark/auth

설명

인증, 토큰, email 발송등 auth에 관하여 클라이언트 (cms/web) 재사용을 위해 공통모듈로 분리 \ cognito라는 회원관리 시스템을 사용함으로써 패스워드등을 별도 보관하여 높은 보안 및 인증에 관한 효율적 사용성을 얻을수 있음

  • react 지원
  • react-native 지원
  • vue.js 지원
  • gatsby.JS 지원
  • next.js(테스트중)

업데이트 내용

  • v1.0.79

회원 가입시 이미 가입되어 있고 인증도 된경우의 에러 코드 변경

code : InvalidParameterException message : User is already confirmed. => code : UsernameExistsException message : User is already confirmed.


필요 파일

각 프로젝트의 개발 환경에 맞는 aws-exports.js 요청

React- native는
<project root>/aws-exports.js

web/cms은
<project root>/src/aws-exports.js

설정 및 설치

yarn add @psyrenpark/auth
yarn add @psyrenpark/api
yarn add @psyrenpark/storage
yarn add amazon-cognito-identity-js
yarn add aws-amplify

// react-native 추가 설치
yarn add @react-native-community/netinfo

최신 버전 적용 방법

새버전 공지시 npm에 맞는것 재설치

yarn add @psyrenpark/auth
yarn add @psyrenpark/api
yarn add @psyrenpark/storage
  • 이후 최신버전으로 변경되었는지 확인할것.

DB tb_user 의 필수 컬럼

프로젝트마다 사용될 유저 테이블에 들어가야할 요소를 서버 개발자에게 요청할것 (중요)

  • email or phone (필수) // 고유키
  • reg_dt
  • up_dt
  • del_dt
  • signup_type // 회원가입 방법
  • user_type // 프로젝트마다 고유한 유저 타입이 있다면 정할것
  • user_name
  • ...

루트에서 세팅

//--------------------------------------------------
// 각 프로젝트 루트
import { Auth } from "@psyrenpark/auth";
import { Api } from "@psyrenpark/api";
import { Storage } from "@psyrenpark/storage";
import awsmobile from "./aws-exports";
Auth.setConfigure(awsmobile);
Api.setConfigure(awsmobile);
Storage.setConfigure(awsmobile);

//--------------------------------------------------
// 혹 import가 지원 되지 않는 javascript 버전에서 사용시

// aws-exports
// export default awsmobile;
module.exports = awsmobile; // 로 변경

// 각 프로젝트 루트
const { apiObject } = require("./api");
const { Api } = require("@psyrenpark/api");
const { Auth, AuthType } = require("@psyrenpark/auth");
const { Storage } = require("@psyrenpark/storage");
const awsmobile = require("./aws-exports");

Auth.setConfigure(awsmobile);
Api.setConfigure(awsmobile);
Storage.setConfigure(awsmobile);

로딩 콜백 함수 샘플

로딩에 대한 불편함을 해결하기위해 콜백형식의 함수를 주입가능하도록 되어있음

// 아래 auth 함수가 실행될때 로딩함수를 넣으면
// 시작시 isLoading => true
// 종료시 isLoading => false
// 로 주입한 함수가 호출됨
const loadingFunction = (isLoading) => {
  dispatch({ type: "SET_IS_LOADING", payload: isLoading });
  // or
  setIsLoading(isLoading);
};

로그인 여부 체크

  • 이미 로그인한적이 있는지 체크하는 함수
  • 이 과정을 한 후에 인증이 필요한 api 사용가능
  • 비동기, 콜백 방식중 필요한 방식을 선택하여 사용할 것
  • cms라면 루트 라우팅에서 로그인창을 띄울지 말지를 결정하는 프로세스(중요)
import { Auth } from "@psyrenpark/auth";

//--------------------------------------
// 비동기 방식
const isCheckToLoginFunction = async (props) => {
  try {
    var auth = await Auth.currentSession();
    // 로그인 상태
    return true;
  } catch (error) {
    // 로그아웃 상태
    return false;
  }
};

//--------------------------------------
// 콜백 방식
const checkToLoginFunction = async (props) => {
  Auth.currentSessionProcess(
    async (data) => {
      // 성공처리 및 로그인 된 상태
      // 자동 로그인 필요
      // 서버에서 유저 정보 필요시 여기서 비동기로 가져올것
    },
    (error) => {
      // 실패처리 및 로그아웃 상태
      // 로그인 화면으로 이동 필요
    },
    loadingFunction
  );
};

특정 그룹 포함 여부 체크

  • 이 유저가 특정 그룹에 속해있는지 체크하는 함수
  • 이로직은 토큰안에 유저 그룹을 포함시키는 방법으로써 각 프로젝트마다 다르니 필요시 요청 (높은 보안 요구시)
  • user, admin, manager 등이 있을수 있다. (기능필요시 요청 할것)
import { Auth } from "@psyrenpark/auth";

//--------------------------------------
// 비동기 방식
const isIncludeGroupFunction = async (props) => {
  try {
    var isIncludeGroup = await Auth.isIncludeGroup("admin");

    if (!isIncludeGroup) {
      await Auth.signOut();
      // 로그아웃하고 홈으로 리다이렉트  및 유저 관련 리덕스 초기화
      return;
    }

    // 정상로직
  } catch (error) {}
};

cms등 특정 유저 로그인을 막기위한 처리 아닌경우 강제 로그아웃

  • cms나 특정 유저만 사용가능 해야할 경우
  • 로그인후 await Auth.currentSession() 로 로그인체크
  • Auth.isIncludeGroup("특정 그룹 key"); 로 토큰안에 key가 포함되어 있는지 체크
  • false 일경우 강제 로그아웃 및 유저 정보 관련된 정보 초기화 할것
const isIncludeGroupFunction = async (props) => {
  try {
    var auth = await Auth.currentSession();
    console.log("checkAuth -> auth", auth);

    var isAdmin = await Auth.isIncludeGroup("admin");

    console.log("checkAuth -> isAdmin", isAdmin);

    if (!isAdmin) {
      // 로그아웃후 리덕스나 내부 저장소 날리는 작업필요
      await Auth.signOut();

      //clear()
      return;
    }
    // 성공후 자기 관리자 정보 가져오기
  } catch (error) {
    // 에러시 강제 로그아웃 후 리덕스나 내부 저장소 날리는 작업필요
    await Auth.signOut();

    //clear()
  }
};

회원가입

  • 회원가입 함수
  • 선인증 필요시 (휴대폰 인증, pass등) 화면 플로우에 맞게 클라이언트에서 미인증 서버 api 호출을 \ 진행후 이결과를 cognitoRegComm안에 포함시켜준다. (중요)
  • 현재 이메일 또는 전화번호 회원가입만 지원한다. (아이디등은 차후)
  • 프로젝트에 따라 이메일을 자동 인증하는 경우와 인증을 체크하는 경우 2가지가 있다.
  • 후인증 필요시 이메일 인증을 하는경우 이메일에 발송된 코드 입력창으로 이동시켜줘야한다.
  • 자동인증 되는 경우 성공시 로그인화면 또는 자동로그인 화면으로 이동해야한다.
  • email은 소문자로 변경해주며, 앞뒤 트림도 해줘야한다.
import { Auth, CurrentAuthUiState, AuthType } from "@psyrenpark/auth";
//--------------------------------------
// 콜백 방식

const signUpFunction = async (formData) => {
  Auth.signUpProcess(
    {
      email: formData.userEmail,
      password: formData.userPassword,
      authType: AuthType.EMAIL,
      lang: "en", // 가입시 유저 선택 언어
      cognitoRegComm: {
        // 서버에서 회원가입시 처리할 필요 파라미터를 첨부한다. 위의 [DB tb_user 의 필수 컬럼] 참고
        // 패스워드 같은 정보는 포함시키지 않는다.
        name: formData.userName,
        age: formData.userAge,
        // ...
      },
    },
    async (data) => {
      // 성공처리 및 회원가입 성공
      // 자동인증인 경우 로그인 화면으로 이동  및 자동로그인 필요
      // 인증을 체크하는 경우 인증코드 입력창 화면으로 이동 필요
    },
    (error) => {
      // 회원가입 실패
    },
    loadingFunction
  );
};

회원가입 후 인증 확인 함수

  • 회원인증 함수
  • 인증을 체크하는 경우 코드입력 화면에서 코드값을 넣어 아래 함수를 실행한다.
import { Auth, CurrentAuthUiState, AuthType } from "@psyrenpark/auth";
//--------------------------------------
// 콜백 방식
const confirmSignUpFunction = async (formData) => {
  Auth.confirmSignUpProcess(
    {
      // 만약 화면 이동을 하였다면 이 변수는 이전화면에서 가져와야할 필요가 있다. (라우팅 porps,redux, context등을 이용)
      email: formData.userEmail,
      password: formData.userPassword,
      authType: AuthType.EMAIL,
      code: formData.userCode, // 이메일에 있는 인증코드
    },
    async (data) => {
      // 성공처리 및 인증 성공
      // 로그인 화면이나 자동로그인 필요
    },
    (error) => {
      // 실패처리,
      // 인증코드가 틀렸을경우 및 만료 된경우
      // 인증 메일 재전송이 필요하다.
    },
    loadingFunction
  );
};

회원가입 후 인증 재전송 함수

  • 유저가 잘못 코드를 입력한경우 또는 재요청 버튼을 클릭한경우 아래 함수 실행
import { Auth, CurrentAuthUiState, AuthType } from "@psyrenpark/auth";
//--------------------------------------
// 콜백 방식
const resendSignUpFunction = async (formData) => {
  Auth.resendSignUpProcess(
    {
      // 만약 화면 이동을 하였다면 이 변수는 이전화면에서 가져와야할 필요가 있다. (라우팅 porps,redux, context등을 이용)
      email: formData.userEmail,
    },
    async (data) => {
      // 성공처리
      // 성공적으로 인증 메일 재전송
    },
    (error) => {
      // 실패처리,
    },
    loadingFunction
  );
};


로그인

  • 로그인 함수
  • 로그아웃 상태일 경우 이 과정을 한후에 인증이 필요한 api 사용가능
  • 인증안하고 종료후 다시 로그인할경우 3번째 파라미터 부분 참고할것
import { Auth, CurrentAuthUiState, AuthType } from "@psyrenpark/auth";

//--------------------------------------
// 콜백 방식
const signInFuntion = async (formData) => {
  Auth.signInProcess(
    {
      email: formData.userEmail,
      password: formData.userPassword,
      authType: AuthType.EMAIL,
    },
    async (data) => {
      // 성공처리 및 로그인 된 상태
      // 서버에서 유저 정보 필요시 여기서 비동기로 가져올것
      // 서버로 자기 정보 가져오는 api를 호출해야한다.
    },
    async (data) => {
      // 회원가입은 되었으나 인증을 안했을경우
      // 인증 안하고 강제 종료 했거나 화면 나갔을경우 타는 함수
      // 인증 화면으로 이동필요
      // 자동으로 인증 메일 재발송됨
    },
    (error) => {
      // 실패처리,
    },
    loadingFunction
  );
};


소셜 로그인 관련

  • 소셜 로그인 라이브러리 추가후 로그인후 나오는 인증 정보중
  • email, password는 고유 아이디, name은 있으면 넣고 없으면 디폴트로 넣어줄것
  • password의 고유 아이디는 앱, 웹 에서 서로 같아야 각각에서 같은 계정으로 로그인이 가능함. (중요)
  • 앱을 여러개로 출시할경우 소셜 유형에 따라 (유저용앱, 관리자용앱) 고유 아이다가 서로 다를수 있으니 주의할것 (예씨 카카오톡)
  • 각 라이브러리(웹, 앱) 로그인후 나오는 토큰 분해후 잘 체크해볼것
  • user_info 필수 param / email, password, name
  • 소셜특징중 이메일을 알아낼수 없는경우 고유한 키를 조합할것 예시 (고유키+@소셜.com (중요)
  • 암호화가 필요할경우 isCrypto:true (테스트중)
import { Auth, CurrentAuthUiState, AuthType } from "@psyrenpark/auth";

//--------------------------------------
// 콜백 방식
const signInProcess = async (
    params = {},
    successCallback,
    failCallback,
    loadingCallback,
  ) => {
    if (loadingCallback) {
      loadingCallback(true);
    }

    try {

      // 중요
      // 쇼설 로그인 라이브러리 추가후 로그인후 나오는 인증 정보중
      // email, password는 고유 아이디, name은 았으면 넣고 없으면 디폴트로 넣어줄것
      // user_info 필수 param / email, password, name
      var user_info = await appleLogin();  //or await googleLogin();
      // var user_info  =
      // {
      //   ...userInfo.user, // 디비에 넣을 필요정보들
      //   email: userInfo.user.email, // 필수
      //   password: userInfo.user.id, // 필수
      //   name: user.givenName,       // 필수
      // }

    } catch (error) {
      if (loadingCallback) {
        loadingCallback(false);
      }

      if (failCallback) {
        failCallback(error);
      return;
    }

    await Auth.signUpProcess(
      {
        email: user_info.email,
        password: user_info.password,
        authType: AuthType.APPLE,         // or  authType: AuthType.GOOGLE, // enum에 없을경우 요청할것
        lang: params.lang ? params.lang : 'en',
        cognitoRegComm: {
          ...user_info,
          ...params,
        },
      },
      async (data) => {
        if (successCallback) {
          await successCallback(data);
        }
      },
      (error) => {
        if (failCallback) {
          failCallback(error);
        }
      },
      (flag) => {
        // if (!flag && loadingCallback) {
        loadingCallback(flag);
        // }
      },
    );
  };


비밀번호 분실 1단계

  • 로그아웃 상태에서 비밀번호를 모를경우
  • 이메일 인증후 새로운 패스워드 설정이 가능하다.
  • 비밀번호 변경은 이메일 타입만 가능하다. 그외에는 비활성
import { Auth, CurrentAuthUiState, AuthType } from "@psyrenpark/auth";

//--------------------------------------
// 콜백 방식
const forgotPasswordFunction = async (formData) => {
  Auth.forgotPasswordProcess(
    {
      email: formData.userEmail,
      authType: AuthType.EMAIL,
    },
    async (data) => {
      // 성공처리 및 인증 메일 발송됨
      // 패스워드 분실 2단계로 이동
    },
    (error) => {
      // 실패처리,
    },
    loadingFunction
  );
};

비밀번호 분실 2단계

  • 유저가 가입한 이메일로 인증메일 발송됨
  • 발송된 유저 코드 입력 와 새로운 패스워드 입력 필요
import { Auth, CurrentAuthUiState, AuthType } from "@psyrenpark/auth";

//--------------------------------------
// 콜백 방식

const confirmForgotPasswordFunction = async (formData) => {
  Auth.confirmForgotPasswordProcess(
    {
      // 만약 화면 이동을 하였다면 이 변수는 이전화면에서 가져와야할 필요가 있다. (라우팅 porps,redux, context등을 이용)
      email: userEmail,
      code: formData.userCode,
      newPassword: formData.userPassword, //새로 지정할 newPassword 이다.
      authType: AuthType.EMAIL,
    },
    async (data) => {
      // 성공처리 및 패스워드 변경
      // 성공하면 자동으로 로그인 되니
      // 바로 메인으로 이동하면됨
    },
    (error) => {
      // 코드 잘못 입력
    },
    loadingFunction
  );
};

비밀번호 분실 2단계 인증 코드 재전송

  • 유저가 잘못 코드를 입력한경우 또는 재요청 버튼을 클릭한경우 아래 함수 실행
import { Auth, CurrentAuthUiState, AuthType } from "@psyrenpark/auth";
//--------------------------------------
// 콜백 방식
const resendForgotPasswordFunction = async (formData) => {
  Auth.resendForgotPasswordProcess(
    {
      // 만약 화면 이동을 하였다면 이 변수는 이전화면에서 가져와야할 필요가 있다. (라우팅 porps,redux, context등을 이용)
      email: formData.userEmail,
      authType: AuthType.EMAIL,
    },
    async (data) => {
      // 성공처리
    },
    (error) => {
      // 실패처리,
    },
    loadingFunction
  );
};


로그인 상태에서 패스워드 변경

  • 로그인된 상태에서 비밀번호를 변경할 경우
  • 여기서는 메일이 발송되지않는다.
  • 이미 로그인된 상태에서 실행해야한다.
import { Auth, CurrentAuthUiState, AuthType } from "@psyrenpark/auth";
//--------------------------------------
// 콜백 방식
const changePasswordFunction = async (formData) => {
    Auth.changePasswordProcess(
      {
        // 만약 화면 이동을 하였다면 이 변수는 이전화면에서 가져와야할 필요가 있다. (라우팅 porps,redux, context등을 이용)
        email: formData.userEmail,
        oldPassword: formData.userOldPassword,
        newPassword: formData.userNewPassword,
        authType: AuthType.EMAIL,
      },
      async (data) => {
        // 성공처리
        // 정상적으로 패스워드 변경
        // 로그아웃 시켜 로그인 화면으로 이동시키는 편이 좋음
      },
      (error) => {
        // 실패처리

      },
      loadingFunction
    );
  );
};


로그아웃

  • 로그아웃을 하면 토큰이 발급되지 않는다
  • isCheckToLoginFunction or checkToLoginFunction에서 실패남
  • 로그아웃후 로그인화면으로 강제 이동필요(중요)
  • 소셜 로그인일경우 성공 처리 사이에 소설 로그인을 넣어서 처리한다.
import { Auth, CurrentAuthUiState, AuthType } from "@psyrenpark/auth";
//--------------------------------------
// 콜백 방식
const signOutFunction = async () => {
  Auth.signOutProcess(
    {
      authType: AuthType.EMAIL,
    },
    async (data) => {
      // 성공처리 및 로그아웃
      // 리덕스나, context의 저장된 정보 초기화 필요
      // 그후 로그인 화면으로 이동
    },
    (error) => {
      // 실패처리,
    },
    loadingFunction
  );
};

유저 탈퇴 관련

  • 실제 amplify에서 삭제 기능은 없으므로 서버딤딩지에게 유저 삭제 api 기능 요청할것
  • 서버 담장자는 express-lib-doc.md 참고
  • 클라이언트는 이 api가 성공후 꼭 반드시 로그아웃후 redux, context안의 유저 정보등 정리 할것
  • 이후 web, cms, react-native등 이후 강제 로그인화면으로 이동 필요

에러 코드 정의

  • 실제 amplify에서 주는 코드 외
  • 이 라이브러리에서 던져 주는 에러코드도 있다
  • 이 목록에 없는 코드 발견시 제보 바람
// amplify에서 error
error.code === "UsernameExistsException"        // email등이 중복되었을 경우
error.code === "InvalidParameterException"      // 필요 파라미터가 부족할경우
error.code === "NotAuthorizedException"         // 인증 권한이 없을경우
error.code === "UserNotFoundException"          // 헤딩 유저가 없을 경우
error.code === "CodeMismatchException"          // 인증 코드가 틀렸을경우
error.code === "LimitExceededException"         // 인증시도 초과시
error.code === "UserLambdaValidationException"  // 개발시난 에러 (api 담당자에게 버그수정 요청)


// custom error
error.code === "NotSignedException"             // 로그인 되지 않았을 경우
error.code === "NotImplementedException"        // 아직 미구현 일경우 (쇼셜 관련 기능)
error.code === "NotSupportAuthTypeException"    // 아직 지원되지 않는 쇼셜 타입
error.code === "SamePasswordException"          // 패스워드 변경시 이전패스워드와 현재 패스워드가 같을경우


// 에러 코드 처리
// - error.code로 검사할것 사용할것

(error) => {
  // 실패처리,
  console.log("changePasswordFunction -> error.code", error.code);
  console.log("changePasswordFunction -> error.name", error.name);
  console.log("changePasswordFunction -> error.message", error.message);
  alert(error.message);
},

// 패스워드를 잘못 입력했을경우 참고 자료
- https://github.com/aws-amplify/amplify-js/issues/1234

- ( 10번 이내 )
code    : NotAuthorizedException
message : "Incorrect username or password"

- ( 10번 이상 )
code    : NotAuthorizedException
message : "Password attempts exceeded"

// 무차별 대입 공격으로부터 보호

- https://stackoverflow.com/questions/37732970/how-aws-cognito-user-pool-defends-against-bruteforce-attacks

`로그인 시도는 5회 실패할 수 있습니다. 그 후 우리는 1초에서 시작하여 각 시도가 실패한 후 최대 약 15분까지 두 배로 증가하는 시간으로 임시 잠금을 시작합니다. 임시 잠금 기간 동안의 시도는 무시됩니다. 임시 잠금 기간이 지난 후 다음 시도가 실패하면 마지막 시간의 두 배 기간으로 새로운 임시 잠금이 시작됩니다. 시도하지 않고 약 15분을 기다리면 임시 잠금도 재설정됩니다. 이 동작은 변경될 수 있습니다.`


이메일 전송 양식 설명

  • 변경필요시 아이콘과 맨트를 적어서 서버관리자에게 전달할것
  • 다국어처리 필요시 서버관리자에게 전달 할것
  • 이메일전송시 일단 기본적 양식으로 날라감

  • 예시 맨트 SBAA0404: "안녕하세요", \ SBAA0405: "싸이페어에 오신 것을 환영합니다", \ SBAA0406: "하단의 인증번호를 싸이페어 화면에 입력해주세요.", \ SBAA0407: "이메일 인증번호", \ SBAA0408: "(주)싸이페어", \ SBAA0409: "", \ SBAA0410: "이 메일은 (주)싸이페어에서 발송되었습니다.", \

추가로 이메일에 붙을 아이콘 첨부바람



react-hook-form 예시

  • 필수는 아니나 사용하면 편리
  • form속성같이 auth화면에서 사용하면 편리하게 개발 가능
  • 필수 속성, 입력값 정규식 체크 등이 편리하게 사용가능
  • 자세한 내용은 https://react-hook-form.com/ 참고
//-------------------------------------------
// react 버전

import React, { useState, useEffect, useContext } from "react";
import { Grid, Checkbox, TextField } from "@material-ui/core";
//-------------------------------------------
// redux
import { useDispatch, useSelector } from "react-redux";

//--------------------------------------------------
// auth
import {
  Auth,
  CurrentAuthUiState,
  AuthType,
  UserState,
} from "@psyrenpark/auth";

//--------------------------------------------------
// hook
import { useForm, useWatch, Controller } from "react-hook-form";
import { useTypedController } from "@hookform/strictly-typed";

//--------------------------------------------------

export const SignInComponent = (props) => {
  const reducer = useSelector((state) => state.reducer);
  const dispatch = useDispatch();

  // useForm 속성
  const {
    register,
    handleSubmit,
    watch,
    errors,
    formState,
    control,
    trigger,
  } = useForm();

  // material ui 사용시
  const TypedController = useTypedController({ control });

  const { toggle } = watch();

  const signInFuntion = async (data) => {
    console.log("signInFuntion -> data", data);

    var userEmail = data.userEmail;
    var userPassword = data.userPassword;
  };

  return (
    <Grid className="sign_in sign">
      {/* Input 사용시 */}
      <Grid className="input_wrap">
        <input
          name="userEmail"
          ref={register({
            required: true,
            pattern: /^[A-Za-z0-9_\.\-]+@[A-Za-z0-9\-]+\.[A-Za-z0-9\-]+/,
          })}
          type="text"
          placeholder="이메일 주소"
        />
      </Grid>

      {/* material ui TextField 사용시 */}
      <Grid className="input_wrap">
        <TypedController
          name="userEmail"
          defaultValue=""
          render={(props) => <TextField {...props} />}
          rules={{
            required: true,
            pattern: /^[A-Za-z0-9_\.\-]+@[A-Za-z0-9\-]+\.[A-Za-z0-9\-]+/,
          }}
        />
      </Grid>

      {errors?.userEmail && (
        <p className="warning">email 정보가 올바르지 않습니다.</p>
      )}
      <Grid className="input_wrap">
        <input
          name="userPassword"
          ref={register({
            required: true,
            pattern: /(?=.*\d{1,50})(?=.*[~`!@#$%\^&*()-+=]{1,50})(?=.*[a-zA-Z]{2,50}).{8,50}$/,
          })}
          type="password"
          placeholder="비밀번호"
          onKeyUp={() => {
            if (window.event.keyCode === 13) {
              handleSubmit(signInFuntion);
            }
          }}
        />
      </Grid>
      {errors.userPassword && (
        <p className="warning">password 정보가 올바르지 않습니다.</p>
      )}
      <button
        type="button"
        className={
          watch("userEmail", false) && watch("userPassword", false)
            ? "btn_move on"
            : "btn_move"
        }
        // className={"btn_move"}
        onClick={handleSubmit(signInFuntion)}
      >
        다음
      </button>
    </Grid>
  );
};
1.0.80

3 years ago

1.0.81

3 years ago

1.0.77

3 years ago

1.0.79

3 years ago

1.0.78

3 years ago

1.0.76

3 years ago

1.0.75

3 years ago

1.0.74

3 years ago

1.0.73

3 years ago

1.0.72

4 years ago

1.0.71

4 years ago

1.0.70

4 years ago

1.0.68

4 years ago

1.0.66

4 years ago

1.0.62

4 years ago

1.0.65

4 years ago

1.0.64

4 years ago

1.0.63

4 years ago

1.0.61

4 years ago

1.0.60

4 years ago

1.0.59

4 years ago

1.0.58

4 years ago

1.0.57

4 years ago

1.0.56

4 years ago

1.0.55

4 years ago

1.0.54

4 years ago

1.0.48

4 years ago

1.0.47

4 years ago

1.0.49

4 years ago

1.0.51

4 years ago

1.0.50

4 years ago

1.0.52

4 years ago

1.0.46

4 years ago

1.0.44

4 years ago

1.0.45

4 years ago

1.0.43

4 years ago

1.0.42

4 years ago

1.0.41

4 years ago

1.0.40

4 years ago

1.0.39

4 years ago

1.0.38

4 years ago

1.0.37

4 years ago

1.0.36

4 years ago

1.0.35

4 years ago

1.0.34

4 years ago

1.0.33

4 years ago

1.0.32

4 years ago

1.0.31

4 years ago

1.0.30

4 years ago

1.0.29

4 years ago

1.0.26

4 years ago

1.0.25

4 years ago

1.0.24

4 years ago

1.0.23

4 years ago

1.0.28

4 years ago

1.0.27

4 years ago

1.0.22

4 years ago

1.0.21

4 years ago

1.0.19

4 years ago

1.0.20

4 years ago

1.0.18

4 years ago

1.0.17

4 years ago

1.0.16

4 years ago

1.0.15

4 years ago

1.0.14

4 years ago

1.0.13

4 years ago

1.0.12

4 years ago

1.0.11

4 years ago

1.0.10

4 years ago

1.0.9

4 years ago

1.0.8

4 years ago

1.0.7

4 years ago

1.0.6

4 years ago

1.0.5

4 years ago

1.0.4

4 years ago

1.0.3

4 years ago

1.0.2

4 years ago