이 가이드는 클래스룸 부가기능 둘러보기 시리즈의 두 번째 둘러보기입니다.
이 둘러보기에서는 웹 애플리케이션에 Google 로그인을 추가합니다. 이는 클래스룸 부가기능의 필수 동작입니다. 이후 모든 API 호출에 이 승인 흐름의 사용자 인증 정보를 사용합니다.
이 둘러보기 과정에서는 다음을 완료합니다.
- iframe 내에서 세션 데이터를 유지하도록 웹 앱을 구성합니다.
- Google OAuth 2.0 서버 간 로그인 흐름을 구현합니다.
- OAuth 2.0 API를 호출합니다.
- API 호출 승인, 로그아웃, 테스트를 지원하는 추가 경로를 만듭니다.
완료되면 웹 앱에서 사용자를 완전히 승인하고 Google API를 호출할 수 있습니다.
승인 흐름 이해
Google API는 인증 및 승인에 OAuth 2.0 프로토콜을 사용합니다. Google OAuth 구현에 대한 자세한 설명은 Google ID OAuth 가이드를 참고하세요.
애플리케이션의 사용자 인증 정보는 Google Cloud에서 관리됩니다. ID를 만든 후 사용자를 승인하고 인증하는 4단계 프로세스를 구현하세요.
- 승인을 요청합니다. 이 요청의 일부로 콜백 URL을 제공합니다. 완료되면 승인 URL을 받게 됩니다.
- 사용자를 승인 URL로 리디렉션합니다. 결과 페이지에서 앱에 필요한 권한을 사용자에게 알리고 액세스를 허용하라는 메시지를 표시합니다. 완료되면 사용자는 콜백 URL로 라우팅됩니다.
- 콜백 경로에서 승인 코드를 받습니다. 액세스 토큰과 갱신 토큰의 승인 코드를 교환합니다.
- 토큰을 사용하여 Google API를 호출합니다.
OAuth 2.0 사용자 인증 정보 가져오기
개요 페이지에 설명된 대로 OAuth 사용자 인증 정보를 만들고 다운로드했는지 확인합니다. 프로젝트에서 사용자 로그인에 이 사용자 인증 정보를 사용해야 합니다.
승인 흐름 구현
웹 앱에 로직과 경로를 추가하여 위에 설명된 흐름을 실현할 수 있도록 다음 기능을 포함합니다.
- 방문 페이지에 도달하면 승인 과정을 시작합니다.
- 승인을 요청하고 승인 서버 응답을 처리합니다.
- 저장된 사용자 인증 정보를 삭제합니다.
- 앱의 권한을 취소합니다.
- API 호출을 테스트합니다.
승인 시작
필요한 경우 승인 절차를 시작하도록 방문 페이지를 수정합니다. 부가기능은 현재 세션에 저장된 토큰과 OAuth 2.0 서버에서 토큰을 가져오는 두 가지 상태일 수 있습니다. 세션에 토큰이 있으면 테스트 API 호출을 수행하고, 그렇지 않으면 사용자에게 로그인하라는 메시지를 표시합니다.
Python
routes.py
파일을 엽니다. 먼저 iframe 보안 권장사항에 따라
몇 가지 상수와 쿠키 구성을 설정합니다.
# The file that contains the OAuth 2.0 client_id and client_secret.
CLIENT_SECRETS_FILE = "client_secret.json"
# The OAuth 2.0 access scopes to request.
# These scopes must match the scopes in your Google Cloud project's OAuth Consent
# Screen: https://console.cloud.google.com/apis/credentials/consent
SCOPES = [
"openid",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/classroom.addons.teacher",
"https://www.googleapis.com/auth/classroom.addons.student"
]
# Flask cookie configurations.
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="None",
)
부가기능 방문 경로 (예시 파일의 /classroom-addon
)로 이동합니다. 세션에 '사용자 인증 정보' 키가 포함되지 않은 경우 로그인 페이지를 렌더링하는 로직을 추가합니다.
@app.route("/classroom-addon")
def classroom_addon():
if "credentials" not in flask.session:
return flask.render_template("authorization.html")
return flask.render_template(
"addon-discovery.html",
message="You've reached the addon discovery page.")
Java
이 둘러보기의 코드는 step_02_sign_in
모듈에서 찾을 수 있습니다.
application.properties
파일을 열고 iframe 보안 권장사항을 따르는 세션 구성을 추가합니다.
# iFrame security recommendations call for cookies to have the HttpOnly and
# secure attribute set
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
# Ensures that the session is maintained across the iframe and sign-in pop-up.
server.servlet.session.cookie.same-site=none
서비스 클래스 (step_02_sign_in
모듈의 AuthService.java
)를 만들어 컨트롤러 파일에서 엔드포인트의 로직을 처리하고 리디렉션 URI, 클라이언트 보안 비밀 파일 위치, 부가기능에 필요한 범위를 설정합니다. 리디렉션 URI는 사용자가 앱을 승인한 후 사용자를 특정 URI로 다시 라우팅하는 데 사용됩니다. client_secret.json
파일을 배치할 위치에 관한 자세한 내용은 소스 코드에 있는 README.md
의 프로젝트 설정 섹션을 참고하세요.
@Service
public class AuthService {
private static final String REDIRECT_URI = "https://localhost:5000/callback";
private static final String CLIENT_SECRET_FILE = "client_secret.json";
private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
private static final String[] REQUIRED_SCOPES = {
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/classroom.addons.teacher",
"https://www.googleapis.com/auth/classroom.addons.student"
};
/** Creates and returns a Collection object with all requested scopes.
* @return Collection of scopes requested by the application.
*/
public static Collection<String> getScopes() {
return new ArrayList<>(Arrays.asList(REQUIRED_SCOPES));
}
}
컨트롤러 파일 (step_02_sign_in
모듈의 AuthController.java
)을 열고 세션에 credentials
키가 포함되지 않은 경우 로그인 페이지를 렌더링하는 로직을 방문 경로에 추가합니다.
@GetMapping(value = {"/start-auth-flow"})
public String startAuthFlow(Model model) {
try {
return "authorization";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
@GetMapping(value = {"/addon-discovery"})
public String addon_discovery(HttpSession session, Model model) {
try {
if (session == null || session.getAttribute("credentials") == null) {
return startAuthFlow(model);
}
return "addon-discovery";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
승인 페이지에는 사용자가 '로그인'할 수 있는 링크 또는 버튼이 있어야 합니다. 클릭하면 사용자가 authorize
경로로 리디렉션됩니다.
승인 요청
승인을 요청하려면 사용자를 인증 URL로 구성하고 리디렉션합니다. 이 URL에는 요청된 범위, 승인 후의 대상 경로, 웹 앱의 클라이언트 ID와 같은 여러 정보가 포함됩니다. 이 샘플 승인 URL에서 확인할 수 있습니다.
Python
routes.py
파일에 다음 가져오기를 추가합니다.
import google_auth_oauthlib.flow
새 경로 /authorize
를 만듭니다. google_auth_oauthlib.flow.Flow
인스턴스를 만듭니다. 이때 포함된 from_client_secrets_file
메서드를 사용하는 것이 좋습니다.
@app.route("/authorize")
def authorize():
# Create flow instance to manage the OAuth 2.0 Authorization Grant Flow
# steps.
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
CLIENT_SECRETS_FILE, scopes=SCOPES)
flow
의 redirect_uri
를 설정합니다. 이는 사용자가 앱을 승인한 후 반환하도록 하려는 경로입니다. 아래 예의 /callback
입니다.
# The URI created here must exactly match one of the authorized redirect
# URIs for the OAuth 2.0 client, which you configured in the API Console. If
# this value doesn't match an authorized URI, you will get a
# "redirect_uri_mismatch" error.
flow.redirect_uri = flask.url_for("callback", _external=True)
흐름 객체를 사용하여 authorization_url
및 state
를 구성합니다. state
는 세션에 저장합니다. 이 값은 나중에 서버 응답의 신뢰성을 확인하는 데 사용됩니다. 마지막으로 사용자를 authorization_url
로 리디렉션합니다.
authorization_url, state = flow.authorization_url(
# Enable offline access so that you can refresh an access token without
# re-prompting the user for permission. Recommended for web server apps.
access_type="offline",
# Enable incremental authorization. Recommended as a best practice.
include_granted_scopes="true")
# Store the state so the callback can verify the auth server response.
flask.session["state"] = state
# Redirect the user to the OAuth authorization URL.
return flask.redirect(authorization_url)
Java
AuthService.java
파일에 다음 메서드를 추가하여 흐름 객체를 인스턴스화한 후 이를 사용하여 승인 URL을 검색합니다.
getClientSecrets()
메서드는 클라이언트 보안 비밀 파일을 읽고GoogleClientSecrets
객체를 구성합니다.getFlow()
메서드는GoogleAuthorizationCodeFlow
의 인스턴스를 만듭니다.authorize()
메서드는GoogleAuthorizationCodeFlow
객체,state
매개변수, 리디렉션 URI를 사용하여 승인 URL을 검색합니다.state
매개변수는 승인 서버로부터 받은 응답의 신뢰성을 확인하는 데 사용됩니다. 그러면 이 메서드는 승인 URL과state
매개변수가 포함된 맵을 반환합니다.
/** Reads the client secret file downloaded from Google Cloud.
* @return GoogleClientSecrets read in from client secret file. */
public GoogleClientSecrets getClientSecrets() throws Exception {
try {
InputStream in = SignInApplication.class.getClassLoader()
.getResourceAsStream(CLIENT_SECRET_FILE);
if (in == null) {
throw new FileNotFoundException("Client secret file not found: "
+ CLIENT_SECRET_FILE);
}
GoogleClientSecrets clientSecrets = GoogleClientSecrets
.load(JSON_FACTORY, new InputStreamReader(in));
return clientSecrets;
} catch (Exception e) {
throw e;
}
}
/** Builds and returns authorization code flow.
* @return GoogleAuthorizationCodeFlow object used to retrieve an access
* token and refresh token for the application.
* @throws Exception if reading client secrets or building code flow object
* is unsuccessful.
*/
public GoogleAuthorizationCodeFlow getFlow() throws Exception {
try {
GoogleAuthorizationCodeFlow authorizationCodeFlow =
new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT,
JSON_FACTORY,
getClientSecrets(),
getScopes())
.setAccessType("offline")
.build();
return authorizationCodeFlow;
} catch (Exception e) {
throw e;
}
}
/** Builds and returns a map with the authorization URL, which allows the
* user to give the app permission to their account, and the state parameter,
* which is used to prevent cross site request forgery.
* @return map with authorization URL and state parameter.
* @throws Exception if building the authorization URL is unsuccessful.
*/
public HashMap authorize() throws Exception {
HashMap<String, String> authDataMap = new HashMap<>();
try {
String state = new BigInteger(130, new SecureRandom()).toString(32);
authDataMap.put("state", state);
GoogleAuthorizationCodeFlow flow = getFlow();
String authUrl = flow
.newAuthorizationUrl()
.setState(state)
.setRedirectUri(REDIRECT_URI)
.build();
String url = authUrl;
authDataMap.put("url", url);
return authDataMap;
} catch (Exception e) {
throw e;
}
}
생성자 삽입을 사용하여 컨트롤러 클래스에 서비스 클래스의 인스턴스를 만듭니다.
/** Declare AuthService to be used in the Controller class constructor. */
private final AuthService authService;
/** AuthController constructor. Uses constructor injection to instantiate
* the AuthService and UserRepository classes.
* @param authService the service class that handles the implementation logic
* of requests.
*/
public AuthController(AuthService authService) {
this.authService = authService;
}
컨트롤러 클래스에 /authorize
엔드포인트를 추가합니다. 이 엔드포인트는 AuthService authorize()
메서드를 호출하여 state
매개변수 및 승인 URL을 검색합니다. 그런 다음 엔드포인트는 세션에 state
매개변수를 저장하고 사용자를 승인 URL로 리디렉션합니다.
/** Redirects the sign-in pop-up to the authorization URL.
* @param response the current response to pass information to.
* @param session the current session.
* @throws Exception if redirection to the authorization URL is unsuccessful.
*/
@GetMapping(value = {"/authorize"})
public void authorize(HttpServletResponse response, HttpSession session)
throws Exception {
try {
HashMap authDataMap = authService.authorize();
String authUrl = authDataMap.get("url").toString();
String state = authDataMap.get("state").toString();
session.setAttribute("state", state);
response.sendRedirect(authUrl);
} catch (Exception e) {
throw e;
}
}
서버 응답 처리
승인 후 사용자는 이전 단계의 redirect_uri
경로로 돌아갑니다. 위의 예에서 이 경로는 /callback
입니다.
사용자가 승인 페이지에서 돌아올 때 응답에 code
를 수신합니다. 그런 다음 코드를 액세스 토큰과 갱신 토큰으로 교환합니다.
Python
Flask 서버 파일에 다음 가져오기를 추가합니다.
import google.oauth2.credentials
import googleapiclient.discovery
서버에 경로를 추가합니다. 다른 google_auth_oauthlib.flow.Flow
인스턴스를 생성하되 이번에는 이전 단계에서 저장한 상태를 재사용합니다.
@app.route("/callback")
def callback():
state = flask.session["state"]
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
CLIENT_SECRETS_FILE, scopes=SCOPES, state=state)
flow.redirect_uri = flask.url_for("callback", _external=True)
다음으로 액세스 및 갱신 토큰을 요청합니다. 다행히 flow
객체에는 이 작업을 실행하는 fetch_token
메서드도 포함되어 있습니다. 이 메서드에는 code
또는 authorization_response
인수가 필요합니다. 요청의 전체 URL이므로 authorization_response
를 사용합니다.
authorization_response = flask.request.url
flow.fetch_token(authorization_response=authorization_response)
이제 완전한 사용자 인증 정보를 받았습니다. 다른 메서드 또는 경로에서 검색할 수 있도록 세션에 저장한 후 부가기능 방문 페이지로 리디렉션합니다.
credentials = flow.credentials
flask.session["credentials"] = {
"token": credentials.token,
"refresh_token": credentials.refresh_token,
"token_uri": credentials.token_uri,
"client_id": credentials.client_id,
"client_secret": credentials.client_secret,
"scopes": credentials.scopes
}
# Close the pop-up by rendering an HTML page with a script that redirects
# the owner and closes itself. This can be done with a bit of JavaScript:
# <script>
# window.opener.location.href = "{{ url_for('classroom_addon') }}";
# window.close();
# </script>
return flask.render_template("close-me.html")
Java
승인 URL이 수행하는 리디렉션에서 가져온 승인 코드를 전달하여 Credentials
객체를 반환하는 메서드를 서비스 클래스에 추가합니다. 이 Credentials
객체는 나중에 액세스 토큰과 갱신 토큰을 검색하는 데 사용됩니다.
/** Returns the required credentials to access Google APIs.
* @param authorizationCode the authorization code provided by the
* authorization URL that's used to obtain credentials.
* @return the credentials that were retrieved from the authorization flow.
* @throws Exception if retrieving credentials is unsuccessful.
*/
public Credential getAndSaveCredentials(String authorizationCode) throws Exception {
try {
GoogleAuthorizationCodeFlow flow = getFlow();
GoogleClientSecrets googleClientSecrets = getClientSecrets();
TokenResponse tokenResponse = flow.newTokenRequest(authorizationCode)
.setClientAuthentication(new ClientParametersAuthentication(
googleClientSecrets.getWeb().getClientId(),
googleClientSecrets.getWeb().getClientSecret()))
.setRedirectUri(REDIRECT_URI)
.execute();
Credential credential = flow.createAndStoreCredential(tokenResponse, null);
return credential;
} catch (Exception e) {
throw e;
}
}
리디렉션 URI의 엔드포인트를 컨트롤러에 추가합니다. 요청에서 승인 코드 및 state
매개변수를 검색합니다. 이 state
매개변수를 세션에 저장된 state
속성과 비교합니다. 일치하면 승인 절차를 계속 진행합니다. 일치하지 않으면 오류를 반환합니다.
그런 다음 AuthService
getAndSaveCredentials
메서드를 호출하고 승인 코드를 매개변수로 전달합니다. Credentials
객체를 가져온 후 세션에 저장합니다. 그런 다음 대화상자를 닫고 사용자를 부가기능 방문 페이지로 리디렉션합니다.
/** Handles the redirect URL to grant the application access to the user's
* account.
* @param request the current request used to obtain the authorization code
* and state parameter from.
* @param session the current session.
* @param response the current response to pass information to.
* @param model the Model interface to pass error information that's
* displayed on the error page.
* @return the close-pop-up template if authorization is successful, or the
* onError method to handle and display the error message.
*/
@GetMapping(value = {"/callback"})
public String callback(HttpServletRequest request, HttpSession session,
HttpServletResponse response, Model model) {
try {
String authCode = request.getParameter("code");
String requestState = request.getParameter("state");
String sessionState = session.getAttribute("state").toString();
if (!requestState.equals(sessionState)) {
response.setStatus(401);
return onError("Invalid state parameter.", model);
}
Credential credentials = authService.getAndSaveCredentials(authCode);
session.setAttribute("credentials", credentials);
return "close-pop-up";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
API 호출 테스트
이 흐름이 완료되면 이제 Google API를 호출할 수 있습니다.
예를 들어 사용자의 프로필 정보를 요청합니다. OAuth 2.0 API에서 사용자 정보를 요청할 수 있습니다.
Python
OAuth 2.0 검색 API 문서를 읽고 이 API를 사용하여 채워진 UserInfo 객체를 가져옵니다.
# Retrieve the credentials from the session data and construct a
# Credentials instance.
credentials = google.oauth2.credentials.Credentials(
**flask.session["credentials"])
# Construct the OAuth 2.0 v2 discovery API library.
user_info_service = googleapiclient.discovery.build(
serviceName="oauth2", version="v2", credentials=credentials)
# Request and store the username in the session.
# This allows it to be used in other methods or in an HTML template.
flask.session["username"] = (
user_info_service.userinfo().get().execute().get("name"))
Java
서비스 클래스에서 Credentials
를 매개변수로 사용하여 UserInfo
객체를 빌드하는 메서드를 만듭니다.
/** Obtains the Userinfo object by passing in the required credentials.
* @param credentials retrieved from the authorization flow.
* @return the Userinfo object for the currently signed-in user.
* @throws IOException if creating UserInfo service or obtaining the
* Userinfo object is unsuccessful.
*/
public Userinfo getUserInfo(Credential credentials) throws IOException {
try {
Oauth2 userInfoService = new Oauth2.Builder(
new NetHttpTransport(),
new GsonFactory(),
credentials).build();
Userinfo userinfo = userInfoService.userinfo().get().execute();
return userinfo;
} catch (Exception e) {
throw e;
}
}
사용자의 이메일을 표시하는 컨트롤러에 /test
엔드포인트를 추가합니다.
/** Returns the test request page with the user's email.
* @param session the current session.
* @param model the Model interface to pass error information that's
* displayed on the error page.
* @return the test page that displays the current user's email or the
* onError method to handle and display the error message.
*/
@GetMapping(value = {"/test"})
public String test(HttpSession session, Model model) {
try {
Credential credentials = (Credential) session.getAttribute("credentials");
Userinfo userInfo = authService.getUserInfo(credentials);
String userInfoEmail = userInfo.getEmail();
if (userInfoEmail != null) {
model.addAttribute("userEmail", userInfoEmail);
} else {
return onError("Could not get user email.", model);
}
return "test";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
사용자 인증 정보 삭제
현재 세션에서 사용자 인증 정보를 삭제하여 '삭제'할 수 있습니다. 이렇게 하면 부가기능 방문 페이지에서 라우팅을 테스트할 수 있습니다.
부가기능 방문 페이지로 리디렉션하기 전에 사용자가 로그아웃했음을 표시하는 것이 좋습니다. 앱은 승인 절차를 거쳐 새 사용자 인증 정보를 얻어야 하지만 사용자에게 앱을 다시 승인하라는 메시지가 표시되지 않습니다.
Python
@app.route("/clear")
def clear_credentials():
if "credentials" in flask.session:
del flask.session["credentials"]
del flask.session["username"]
return flask.render_template("signed-out.html")
또는 flask.session.clear()
를 사용하지만 세션에 다른 값이 저장된 경우 의도하지 않은 영향이 발생할 수 있습니다.
Java
컨트롤러에서 /clear
엔드포인트를 추가합니다.
/** Clears the credentials in the session and returns the sign-out
* confirmation page.
* @param session the current session.
* @return the sign-out confirmation page.
*/
@GetMapping(value = {"/clear"})
public String clear(HttpSession session) {
try {
if (session != null && session.getAttribute("credentials") != null) {
session.removeAttribute("credentials");
}
return "sign-out";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
앱 권한 취소
사용자는 POST
요청을 https://oauth2.googleapis.com/revoke
에 전송하여 앱의 권한을 취소할 수 있습니다. 요청에는 사용자의 액세스 토큰이 포함되어야 합니다.
Python
import requests
@app.route("/revoke")
def revoke():
if "credentials" not in flask.session:
return flask.render_template("addon-discovery.html",
message="You need to authorize before " +
"attempting to revoke credentials.")
credentials = google.oauth2.credentials.Credentials(
**flask.session["credentials"])
revoke = requests.post(
"https://oauth2.googleapis.com/revoke",
params={"token": credentials.token},
headers={"content-type": "application/x-www-form-urlencoded"})
if "credentials" in flask.session:
del flask.session["credentials"]
del flask.session["username"]
status_code = getattr(revoke, "status_code")
if status_code == 200:
return flask.render_template("authorization.html")
else:
return flask.render_template(
"index.html", message="An error occurred during revocation!")
Java
취소 엔드포인트를 호출하는 메서드를 서비스 클래스에 추가합니다.
/** Revokes the app's permissions to the user's account.
* @param credentials retrieved from the authorization flow.
* @return response entity returned from the HTTP call to obtain response
* information.
* @throws RestClientException if the POST request to the revoke endpoint is
* unsuccessful.
*/
public ResponseEntity<String> revokeCredentials(Credential credentials) throws RestClientException {
try {
String accessToken = credentials.getAccessToken();
String url = "https://oauth2.googleapis.com/revoke?token=" + accessToken;
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE);
HttpEntity<Object> httpEntity = new HttpEntity<Object>(httpHeaders);
ResponseEntity<String> responseEntity = new RestTemplate().exchange(
url,
HttpMethod.POST,
httpEntity,
String.class);
return responseEntity;
} catch (RestClientException e) {
throw e;
}
}
세션을 지우고 취소에 성공하면 사용자를 승인 페이지로 리디렉션하는 컨트롤러에 /revoke
엔드포인트를 추가합니다.
/** Revokes the app's permissions and returns the authorization page.
* @param session the current session.
* @return the authorization page.
* @throws Exception if revoking access is unsuccessful.
*/
@GetMapping(value = {"/revoke"})
public String revoke(HttpSession session) throws Exception {
try {
if (session != null && session.getAttribute("credentials") != null) {
Credential credentials = (Credential) session.getAttribute("credentials");
ResponseEntity responseEntity = authService.revokeCredentials(credentials);
Integer httpStatusCode = responseEntity.getStatusCodeValue();
if (httpStatusCode != 200) {
return onError("There was an issue revoking access: " +
responseEntity.getStatusCode(), model);
}
session.removeAttribute("credentials");
}
return startAuthFlow(model);
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
부가기능 테스트
교사 테스트 사용자 중 하나로 Google 클래스룸에 로그인합니다. 수업 과제 탭으로 이동하여 새 과제를 만듭니다. 텍스트 영역 아래의 부가기능 버튼을 클릭한 다음 부가기능을 선택합니다. iframe이 열리고 부가기능이 GWM SDK의 앱 구성 페이지에서 지정한 첨부파일 설정 URI를 로드합니다.
수고하셨습니다 다음 단계인 부가기능 반복 방문 처리를 진행할 준비가 되었습니다.