이 둘러보기는 클래스룸 부가기능 둘러보기 시리즈의 두 번째 둘러보기입니다.
이 워크스루에서는 웹 애플리케이션에 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에서 관리됩니다. 이러한 항목이 생성되면 사용자를 인증하고 승인하는 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
)로 이동합니다. 세션에 'credentials' 키가 포함되지 않은 경우 로그인 페이지를 렌더링하는 로직을 추가합니다.
@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.")
자바
이 둘러보기의 코드는 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
컨트롤러 파일에서 엔드포인트 뒤의 로직을 처리하고 리디렉션 URI, 클라이언트 비밀 파일 위치, 부가기능에 필요한 범위를 설정하는 서비스 클래스 (step_02_sign_in
모듈의 AuthService.java
)를 만듭니다. 리디렉션 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)
자바
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
인수가 필요합니다. authorization_response
를 사용하세요. 요청의 전체 URL이기 때문입니다.
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")
자바
승인 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 문서를 읽고 이를 사용하여 채워진 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"))
자바
서비스 클래스에서 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()
를 사용해도 되지만 세션에 다른 값이 저장되어 있는 경우 의도치 않은 효과가 발생할 수 있습니다.
자바
컨트롤러에서 /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);
}
}
앱의 권한 취소
사용자는 https://oauth2.googleapis.com/revoke
에 POST
요청을 전송하여 앱의 권한을 취소할 수 있습니다. 요청에는 사용자의 액세스 토큰이 포함되어야 합니다.
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!")
자바
취소 엔드포인트를 호출하는 메서드를 서비스 클래스에 추가합니다.
/** 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를 로드합니다.
축하합니다. 다음 단계인 부가기능의 반복 방문 처리로 진행할 준비가 되었습니다.