4850 |
14 Jun 18 |
nicklas |
1 |
package net.sf.basedb.otp; |
4850 |
14 Jun 18 |
nicklas |
2 |
|
4850 |
14 Jun 18 |
nicklas |
3 |
import java.awt.image.BufferedImage; |
4850 |
14 Jun 18 |
nicklas |
4 |
import java.io.ByteArrayOutputStream; |
4850 |
14 Jun 18 |
nicklas |
5 |
import java.io.IOException; |
4850 |
14 Jun 18 |
nicklas |
6 |
import java.util.Base64; |
4852 |
15 Jun 18 |
nicklas |
7 |
import java.util.Collections; |
4852 |
15 Jun 18 |
nicklas |
8 |
import java.util.Map; |
4852 |
15 Jun 18 |
nicklas |
9 |
import java.util.WeakHashMap; |
4850 |
14 Jun 18 |
nicklas |
10 |
|
4850 |
14 Jun 18 |
nicklas |
11 |
import javax.imageio.ImageIO; |
4851 |
14 Jun 18 |
nicklas |
12 |
import javax.security.auth.login.LoginException; |
4850 |
14 Jun 18 |
nicklas |
13 |
import javax.servlet.ServletException; |
4850 |
14 Jun 18 |
nicklas |
14 |
import javax.servlet.http.HttpServlet; |
4850 |
14 Jun 18 |
nicklas |
15 |
import javax.servlet.http.HttpServletRequest; |
4850 |
14 Jun 18 |
nicklas |
16 |
import javax.servlet.http.HttpServletResponse; |
4850 |
14 Jun 18 |
nicklas |
17 |
|
4850 |
14 Jun 18 |
nicklas |
18 |
import org.json.simple.JSONObject; |
4850 |
14 Jun 18 |
nicklas |
19 |
|
4850 |
14 Jun 18 |
nicklas |
20 |
import com.warrenstrange.googleauth.GoogleAuthenticator; |
4850 |
14 Jun 18 |
nicklas |
21 |
import com.warrenstrange.googleauth.GoogleAuthenticatorKey; |
4850 |
14 Jun 18 |
nicklas |
22 |
import com.warrenstrange.googleauth.GoogleAuthenticatorQRGenerator; |
4850 |
14 Jun 18 |
nicklas |
23 |
|
4850 |
14 Jun 18 |
nicklas |
24 |
import io.nayuki.qrcodegen.QrCode; |
4850 |
14 Jun 18 |
nicklas |
25 |
import net.sf.basedb.clients.web.Base; |
4850 |
14 Jun 18 |
nicklas |
26 |
import net.sf.basedb.core.Application; |
4857 |
18 Jun 18 |
nicklas |
27 |
import net.sf.basedb.core.DbControl; |
4850 |
14 Jun 18 |
nicklas |
28 |
import net.sf.basedb.core.SessionControl; |
4857 |
18 Jun 18 |
nicklas |
29 |
import net.sf.basedb.core.User; |
4851 |
14 Jun 18 |
nicklas |
30 |
import net.sf.basedb.core.authentication.LoginRequest; |
4850 |
14 Jun 18 |
nicklas |
31 |
import net.sf.basedb.util.Values; |
4850 |
14 Jun 18 |
nicklas |
32 |
import net.sf.basedb.util.error.ThrowableUtil; |
4850 |
14 Jun 18 |
nicklas |
33 |
|
4850 |
14 Jun 18 |
nicklas |
34 |
|
4850 |
14 Jun 18 |
nicklas |
35 |
|
4850 |
14 Jun 18 |
nicklas |
36 |
/** |
4850 |
14 Jun 18 |
nicklas |
Servlet class for performing OTP-related tasks |
4850 |
14 Jun 18 |
nicklas |
for the web interface. |
4850 |
14 Jun 18 |
nicklas |
39 |
|
4850 |
14 Jun 18 |
nicklas |
@author nicklas |
4850 |
14 Jun 18 |
nicklas |
@since 1.0 |
4850 |
14 Jun 18 |
nicklas |
42 |
*/ |
4850 |
14 Jun 18 |
nicklas |
43 |
public class OtpServlet |
4850 |
14 Jun 18 |
nicklas |
44 |
extends HttpServlet |
4850 |
14 Jun 18 |
nicklas |
45 |
{ |
4850 |
14 Jun 18 |
nicklas |
46 |
|
4850 |
14 Jun 18 |
nicklas |
47 |
private static final long serialVersionUID = -2684685679966855942L; |
4850 |
14 Jun 18 |
nicklas |
48 |
|
4852 |
15 Jun 18 |
nicklas |
49 |
private static final Map<SessionControl, String> temporaryKeys = |
4852 |
15 Jun 18 |
nicklas |
50 |
Collections.synchronizedMap(new WeakHashMap<>()); |
4851 |
14 Jun 18 |
nicklas |
51 |
|
4850 |
14 Jun 18 |
nicklas |
52 |
public OtpServlet() |
4850 |
14 Jun 18 |
nicklas |
53 |
{} |
4850 |
14 Jun 18 |
nicklas |
54 |
|
4850 |
14 Jun 18 |
nicklas |
55 |
@Override |
4850 |
14 Jun 18 |
nicklas |
56 |
protected void doGet(HttpServletRequest req, HttpServletResponse resp) |
4850 |
14 Jun 18 |
nicklas |
57 |
throws ServletException, IOException |
4850 |
14 Jun 18 |
nicklas |
58 |
{ |
4850 |
14 Jun 18 |
nicklas |
59 |
final String ID = req.getParameter("ID"); |
4850 |
14 Jun 18 |
nicklas |
60 |
final String cmd = req.getParameter("cmd"); |
4850 |
14 Jun 18 |
nicklas |
61 |
|
4851 |
14 Jun 18 |
nicklas |
62 |
JsonUtil.setJsonResponseHeaders(resp); |
4850 |
14 Jun 18 |
nicklas |
63 |
|
4850 |
14 Jun 18 |
nicklas |
64 |
JSONObject json = new JSONObject(); |
4850 |
14 Jun 18 |
nicklas |
65 |
json.put("status", "ok"); |
4923 |
10 Aug 18 |
nicklas |
66 |
|
4923 |
10 Aug 18 |
nicklas |
67 |
DbControl dc = null; |
4850 |
14 Jun 18 |
nicklas |
68 |
try |
4850 |
14 Jun 18 |
nicklas |
69 |
{ |
4850 |
14 Jun 18 |
nicklas |
70 |
|
4850 |
14 Jun 18 |
nicklas |
71 |
if ("CreateQRCode".equals(cmd)) |
4850 |
14 Jun 18 |
nicklas |
72 |
{ |
4923 |
10 Aug 18 |
nicklas |
73 |
final SessionControl sc = Application.getSessionControl(ID, Base.WEBCLIENT_ID, req.getRemoteAddr(), false); |
4923 |
10 Aug 18 |
nicklas |
74 |
|
4850 |
14 Jun 18 |
nicklas |
75 |
String username = Values.getStringOrNull(req.getParameter("username")); |
4850 |
14 Jun 18 |
nicklas |
76 |
if (username == null) |
4850 |
14 Jun 18 |
nicklas |
77 |
{ |
4850 |
14 Jun 18 |
nicklas |
78 |
throw new NullPointerException("A username must be specified"); |
4850 |
14 Jun 18 |
nicklas |
79 |
} |
4850 |
14 Jun 18 |
nicklas |
80 |
|
4850 |
14 Jun 18 |
nicklas |
// Generate a random key (with twice the default length = 160 bits) |
4850 |
14 Jun 18 |
nicklas |
82 |
GoogleAuthenticator gAuth = new GoogleAuthenticator(); |
4850 |
14 Jun 18 |
nicklas |
83 |
String key1 = gAuth.createCredentials().getKey(); |
4850 |
14 Jun 18 |
nicklas |
84 |
String key2 = gAuth.createCredentials().getKey(); |
4850 |
14 Jun 18 |
nicklas |
85 |
GoogleAuthenticatorKey gKey = new GoogleAuthenticatorKey.Builder(key1+key2).build(); |
4850 |
14 Jun 18 |
nicklas |
86 |
|
4850 |
14 Jun 18 |
nicklas |
// Use host name + path which should give the URL to the BASE server |
4850 |
14 Jun 18 |
nicklas |
88 |
String issuer = Application.getHostName() + req.getContextPath(); |
4850 |
14 Jun 18 |
nicklas |
89 |
String totpURL = GoogleAuthenticatorQRGenerator.getOtpAuthTotpURL(issuer, username, gKey); |
4850 |
14 Jun 18 |
nicklas |
90 |
|
4850 |
14 Jun 18 |
nicklas |
// Create the QR code |
4850 |
14 Jun 18 |
nicklas |
92 |
QrCode qrCode = QrCode.encodeText(totpURL, QrCode.Ecc.LOW); |
4850 |
14 Jun 18 |
nicklas |
93 |
|
4850 |
14 Jun 18 |
nicklas |
// And get it as a png image |
4850 |
14 Jun 18 |
nicklas |
95 |
BufferedImage img = qrCode.toImage(3, 0); |
4850 |
14 Jun 18 |
nicklas |
96 |
ByteArrayOutputStream png = new ByteArrayOutputStream(1024); |
4850 |
14 Jun 18 |
nicklas |
97 |
ImageIO.write(img, "png", png); |
4850 |
14 Jun 18 |
nicklas |
98 |
|
4850 |
14 Jun 18 |
nicklas |
// Encode it as Base64 to let the web client use <img src="data:image/png;base64,..."> |
4850 |
14 Jun 18 |
nicklas |
100 |
String pngInBase64 = Base64.getEncoder().encodeToString(png.toByteArray()); |
4850 |
14 Jun 18 |
nicklas |
101 |
json.put("image", pngInBase64); |
4851 |
14 Jun 18 |
nicklas |
102 |
|
4852 |
15 Jun 18 |
nicklas |
103 |
temporaryKeys.put(sc, gKey.getKey()); |
4923 |
10 Aug 18 |
nicklas |
104 |
} |
4923 |
10 Aug 18 |
nicklas |
105 |
else if ("DisplayQRCode".equals(cmd)) |
4923 |
10 Aug 18 |
nicklas |
106 |
{ |
4923 |
10 Aug 18 |
nicklas |
107 |
final SessionControl sc = Application.getSessionControl(ID, Base.WEBCLIENT_ID, req.getRemoteAddr(), true); |
4923 |
10 Aug 18 |
nicklas |
108 |
String otp = Values.getStringOrNull(req.getParameter("otp")); |
4923 |
10 Aug 18 |
nicklas |
// Check that the OTP is ok |
4923 |
10 Aug 18 |
nicklas |
110 |
if (otp == null || !otp.matches("\\d{6}")) |
4923 |
10 Aug 18 |
nicklas |
111 |
{ |
4923 |
10 Aug 18 |
nicklas |
// It seems like the user did not enter a 6-digit code |
4923 |
10 Aug 18 |
nicklas |
113 |
throw new IllegalArgumentException("The one-time passcode must have 6 digits."); |
4923 |
10 Aug 18 |
nicklas |
114 |
} |
4852 |
15 Jun 18 |
nicklas |
115 |
|
4923 |
10 Aug 18 |
nicklas |
116 |
dc = sc.newDbControl(); |
4923 |
10 Aug 18 |
nicklas |
117 |
User user = User.getById(dc, sc.getLoggedInUserId()); |
4923 |
10 Aug 18 |
nicklas |
118 |
String username = user.getLogin(); |
4923 |
10 Aug 18 |
nicklas |
119 |
|
4923 |
10 Aug 18 |
nicklas |
// De-crypt the OTP secret key |
4923 |
10 Aug 18 |
nicklas |
121 |
String otpKeyEncrypted = (String)user.getExtended("otpSecretKey"); |
4923 |
10 Aug 18 |
nicklas |
122 |
if (otpKeyEncrypted == null) |
4923 |
10 Aug 18 |
nicklas |
123 |
{ |
4923 |
10 Aug 18 |
nicklas |
124 |
throw new NullPointerException("OTP has not been enabled for user '" + username + "'"); |
4923 |
10 Aug 18 |
nicklas |
125 |
} |
4923 |
10 Aug 18 |
nicklas |
126 |
String otpKey = CryptUtil.decrypt(otpKeyEncrypted, user.getId()); |
4923 |
10 Aug 18 |
nicklas |
127 |
|
4923 |
10 Aug 18 |
nicklas |
// Validate the OTP value |
4923 |
10 Aug 18 |
nicklas |
129 |
GoogleAuthenticator gAuth = new GoogleAuthenticator(); |
4923 |
10 Aug 18 |
nicklas |
130 |
if (!gAuth.authorize(otpKey, Values.getInt(otp))) |
4923 |
10 Aug 18 |
nicklas |
131 |
{ |
4923 |
10 Aug 18 |
nicklas |
132 |
throw new IllegalArgumentException("Invalid one-time passcode. Please try again."); |
4923 |
10 Aug 18 |
nicklas |
133 |
} |
4924 |
13 Aug 18 |
nicklas |
134 |
if (!UsedOtpCodes.INSTANCE.useCode(user.getId(), otp)) |
4924 |
13 Aug 18 |
nicklas |
135 |
{ |
4924 |
13 Aug 18 |
nicklas |
136 |
throw new IllegalArgumentException("The one-time passcode has already been used."); |
4924 |
13 Aug 18 |
nicklas |
137 |
} |
4923 |
10 Aug 18 |
nicklas |
138 |
|
4923 |
10 Aug 18 |
nicklas |
// Generate the URI that should be encoded in the QR code |
4923 |
10 Aug 18 |
nicklas |
140 |
GoogleAuthenticatorKey gKey = new GoogleAuthenticatorKey.Builder(otpKey).build(); |
4923 |
10 Aug 18 |
nicklas |
// Use host name + path which should give the URL to the BASE server |
4923 |
10 Aug 18 |
nicklas |
142 |
String issuer = Application.getHostName() + req.getContextPath(); |
4923 |
10 Aug 18 |
nicklas |
143 |
String totpURL = GoogleAuthenticatorQRGenerator.getOtpAuthTotpURL(issuer, username, gKey); |
4923 |
10 Aug 18 |
nicklas |
144 |
|
4923 |
10 Aug 18 |
nicklas |
// Create the QR code |
4923 |
10 Aug 18 |
nicklas |
146 |
QrCode qrCode = QrCode.encodeText(totpURL, QrCode.Ecc.LOW); |
4923 |
10 Aug 18 |
nicklas |
147 |
|
4923 |
10 Aug 18 |
nicklas |
// And get it as a png image |
4923 |
10 Aug 18 |
nicklas |
149 |
BufferedImage img = qrCode.toImage(3, 0); |
4923 |
10 Aug 18 |
nicklas |
150 |
ByteArrayOutputStream png = new ByteArrayOutputStream(1024); |
4923 |
10 Aug 18 |
nicklas |
151 |
ImageIO.write(img, "png", png); |
4923 |
10 Aug 18 |
nicklas |
152 |
|
4923 |
10 Aug 18 |
nicklas |
// Encode it as Base64 to let the web client use <img src="data:image/png;base64,..."> |
4923 |
10 Aug 18 |
nicklas |
154 |
String pngInBase64 = Base64.getEncoder().encodeToString(png.toByteArray()); |
4923 |
10 Aug 18 |
nicklas |
155 |
json.put("image", pngInBase64); |
4924 |
13 Aug 18 |
nicklas |
156 |
json.put("issuer", issuer); |
4924 |
13 Aug 18 |
nicklas |
157 |
json.put("username", username); |
4850 |
14 Jun 18 |
nicklas |
158 |
} |
4850 |
14 Jun 18 |
nicklas |
159 |
} |
4850 |
14 Jun 18 |
nicklas |
160 |
catch (Throwable t) |
4850 |
14 Jun 18 |
nicklas |
161 |
{ |
4850 |
14 Jun 18 |
nicklas |
162 |
t.printStackTrace(System.out); |
4850 |
14 Jun 18 |
nicklas |
163 |
json.clear(); |
4850 |
14 Jun 18 |
nicklas |
164 |
json.put("status", "error"); |
4850 |
14 Jun 18 |
nicklas |
165 |
json.put("message", t.getMessage()); |
4850 |
14 Jun 18 |
nicklas |
166 |
json.put("stacktrace", ThrowableUtil.stackTraceToString(t)); |
4850 |
14 Jun 18 |
nicklas |
167 |
} |
4850 |
14 Jun 18 |
nicklas |
168 |
finally |
4850 |
14 Jun 18 |
nicklas |
169 |
{ |
4923 |
10 Aug 18 |
nicklas |
170 |
if (dc != null) dc.close(); |
4850 |
14 Jun 18 |
nicklas |
171 |
json.writeJSONString(resp.getWriter()); |
4850 |
14 Jun 18 |
nicklas |
172 |
} |
4850 |
14 Jun 18 |
nicklas |
173 |
} |
4850 |
14 Jun 18 |
nicklas |
174 |
|
4851 |
14 Jun 18 |
nicklas |
175 |
@Override |
4851 |
14 Jun 18 |
nicklas |
176 |
protected void doPost(HttpServletRequest req, HttpServletResponse resp) |
4851 |
14 Jun 18 |
nicklas |
177 |
throws ServletException, IOException |
4851 |
14 Jun 18 |
nicklas |
178 |
{ |
4851 |
14 Jun 18 |
nicklas |
179 |
final String ID = req.getParameter("ID"); |
4851 |
14 Jun 18 |
nicklas |
180 |
final String cmd = req.getParameter("cmd"); |
4850 |
14 Jun 18 |
nicklas |
181 |
|
4851 |
14 Jun 18 |
nicklas |
182 |
JsonUtil.setJsonResponseHeaders(resp); |
4851 |
14 Jun 18 |
nicklas |
183 |
|
4851 |
14 Jun 18 |
nicklas |
184 |
JSONObject json = new JSONObject(); |
4851 |
14 Jun 18 |
nicklas |
185 |
json.put("status", "ok"); |
4851 |
14 Jun 18 |
nicklas |
186 |
|
4857 |
18 Jun 18 |
nicklas |
187 |
DbControl dc = null; |
4851 |
14 Jun 18 |
nicklas |
188 |
try |
4851 |
14 Jun 18 |
nicklas |
189 |
{ |
4851 |
14 Jun 18 |
nicklas |
190 |
final SessionControl sc = Application.getSessionControl(ID, Base.WEBCLIENT_ID, req.getRemoteAddr(), false); |
4851 |
14 Jun 18 |
nicklas |
191 |
|
4851 |
14 Jun 18 |
nicklas |
192 |
if ("SaveOTPSetup".equals(cmd)) |
4851 |
14 Jun 18 |
nicklas |
193 |
{ |
4851 |
14 Jun 18 |
nicklas |
194 |
JSONObject jsonReq = JsonUtil.parseRequest(req); |
4851 |
14 Jun 18 |
nicklas |
195 |
|
4851 |
14 Jun 18 |
nicklas |
196 |
String username = (String)jsonReq.get("username"); |
4851 |
14 Jun 18 |
nicklas |
197 |
String password = (String)jsonReq.get("password"); |
4851 |
14 Jun 18 |
nicklas |
198 |
String otp = (String)jsonReq.get("otp"); |
4857 |
18 Jun 18 |
nicklas |
199 |
String newPassword = (String)jsonReq.get("newPassword"); |
4851 |
14 Jun 18 |
nicklas |
200 |
|
4851 |
14 Jun 18 |
nicklas |
// Check that the OTP is ok and valid |
4851 |
14 Jun 18 |
nicklas |
202 |
if (otp == null || !otp.matches("\\d{6}")) |
4851 |
14 Jun 18 |
nicklas |
203 |
{ |
4851 |
14 Jun 18 |
nicklas |
// It seems like the user did not enter a 6-digit code |
4851 |
14 Jun 18 |
nicklas |
205 |
throw new LoginException("The one-time passcode must have 6 digits."); |
4851 |
14 Jun 18 |
nicklas |
206 |
} |
4852 |
15 Jun 18 |
nicklas |
207 |
|
4852 |
15 Jun 18 |
nicklas |
208 |
String secretKey = temporaryKeys.get(sc); |
4852 |
15 Jun 18 |
nicklas |
209 |
if (secretKey == null) |
4852 |
15 Jun 18 |
nicklas |
210 |
{ |
4852 |
15 Jun 18 |
nicklas |
211 |
throw new NullPointerException("Could not find the OTP secret key. Please restart the wizard and try again."); |
4852 |
15 Jun 18 |
nicklas |
212 |
} |
4852 |
15 Jun 18 |
nicklas |
213 |
|
4851 |
14 Jun 18 |
nicklas |
214 |
GoogleAuthenticator gAuth = new GoogleAuthenticator(); |
4852 |
15 Jun 18 |
nicklas |
215 |
if (!gAuth.authorize(secretKey, Values.getInt(otp))) |
4851 |
14 Jun 18 |
nicklas |
216 |
{ |
4851 |
14 Jun 18 |
nicklas |
217 |
throw new LoginException("Invalid one-time passcode. Please try again."); |
4851 |
14 Jun 18 |
nicklas |
218 |
} |
4850 |
14 Jun 18 |
nicklas |
219 |
|
4851 |
14 Jun 18 |
nicklas |
// Generate a login request and set mode=SETUP_MODE |
4851 |
14 Jun 18 |
nicklas |
// This will trigger SetupOtpAuthenticationManger to store the secret key |
4851 |
14 Jun 18 |
nicklas |
222 |
LoginRequest login = new LoginRequest(username, password); |
4851 |
14 Jun 18 |
nicklas |
223 |
login.setComment("Setting up OTP"); |
5153 |
28 Nov 18 |
nicklas |
224 |
login.setAttribute("login-form", Otp.SETUP_MODE); |
4852 |
15 Jun 18 |
nicklas |
225 |
login.setAttribute("otpSecretKey", secretKey); |
4851 |
14 Jun 18 |
nicklas |
226 |
sc.login(login); |
4857 |
18 Jun 18 |
nicklas |
227 |
|
4857 |
18 Jun 18 |
nicklas |
228 |
String message = "OTP setup completed successfully."; |
4857 |
18 Jun 18 |
nicklas |
229 |
if (newPassword != null) |
4857 |
18 Jun 18 |
nicklas |
230 |
{ |
4857 |
18 Jun 18 |
nicklas |
231 |
try |
4857 |
18 Jun 18 |
nicklas |
232 |
{ |
4857 |
18 Jun 18 |
nicklas |
233 |
dc = sc.newDbControl(); |
4857 |
18 Jun 18 |
nicklas |
234 |
User user = User.getById(dc, sc.getLoggedInUserId()); |
4857 |
18 Jun 18 |
nicklas |
235 |
user.setPassword(newPassword); |
4857 |
18 Jun 18 |
nicklas |
236 |
dc.commit(); |
4857 |
18 Jun 18 |
nicklas |
237 |
message = "OTP setup completed successfully and the password has been changed."; |
4857 |
18 Jun 18 |
nicklas |
238 |
} |
4857 |
18 Jun 18 |
nicklas |
239 |
catch (Exception ex) |
4857 |
18 Jun 18 |
nicklas |
240 |
{ |
4857 |
18 Jun 18 |
nicklas |
241 |
message = "OTP setup completed successfully, but the password could not be changed (" + ex.getMessage() + ")."; |
4857 |
18 Jun 18 |
nicklas |
242 |
} |
4857 |
18 Jun 18 |
nicklas |
243 |
} |
4857 |
18 Jun 18 |
nicklas |
244 |
json.put("message", message); |
4857 |
18 Jun 18 |
nicklas |
245 |
|
4852 |
15 Jun 18 |
nicklas |
246 |
temporaryKeys.remove(sc); |
4851 |
14 Jun 18 |
nicklas |
247 |
sc.logout(); |
4851 |
14 Jun 18 |
nicklas |
248 |
} |
4851 |
14 Jun 18 |
nicklas |
249 |
|
4851 |
14 Jun 18 |
nicklas |
250 |
} |
4851 |
14 Jun 18 |
nicklas |
251 |
catch (Throwable t) |
4851 |
14 Jun 18 |
nicklas |
252 |
{ |
4851 |
14 Jun 18 |
nicklas |
253 |
t.printStackTrace(System.out); |
4851 |
14 Jun 18 |
nicklas |
254 |
json.clear(); |
4851 |
14 Jun 18 |
nicklas |
255 |
json.put("status", "error"); |
4851 |
14 Jun 18 |
nicklas |
256 |
json.put("message", t.getMessage()); |
4851 |
14 Jun 18 |
nicklas |
257 |
json.put("stacktrace", ThrowableUtil.stackTraceToString(t)); |
4851 |
14 Jun 18 |
nicklas |
258 |
} |
4851 |
14 Jun 18 |
nicklas |
259 |
finally |
4851 |
14 Jun 18 |
nicklas |
260 |
{ |
4851 |
14 Jun 18 |
nicklas |
261 |
json.writeJSONString(resp.getWriter()); |
4857 |
18 Jun 18 |
nicklas |
262 |
if (dc != null) dc.close(); |
4851 |
14 Jun 18 |
nicklas |
263 |
} |
4851 |
14 Jun 18 |
nicklas |
264 |
} |
4851 |
14 Jun 18 |
nicklas |
265 |
|
4851 |
14 Jun 18 |
nicklas |
266 |
|
4850 |
14 Jun 18 |
nicklas |
267 |
} |